An ATproto social media client -- with an independent Appview.

chore: merge from upstream at 2025-08-28-105500

Sync with upstream

authored by

serenity and committed by
GitHub
d319ff11 89bae0fa

+2257 -1328
assets/images/onboarding/value_prop_1_dark.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_1_dark_borderless.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_1_dim.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_1_dim_borderless.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_1_light.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_1_light_borderless.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_2_dark.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_2_dim.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_2_light.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_3_dark.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_3_dim.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_3_light.webp

This is a binary file and will not be displayed.

+31
src/alf/atoms.ts
··· 70 70 overflow_visible: { 71 71 overflow: 'visible', 72 72 }, 73 + overflow_x_visible: { 74 + overflowX: 'visible', 75 + }, 76 + overflow_y_visible: { 77 + overflowY: 'visible', 78 + }, 73 79 overflow_hidden: { 74 80 overflow: 'hidden', 81 + }, 82 + overflow_x_hidden: { 83 + overflowX: 'hidden', 84 + }, 85 + overflow_y_hidden: { 86 + overflowY: 'hidden', 75 87 }, 76 88 /** 77 89 * @platform web ··· 363 375 border_r_0: { 364 376 borderRightWidth: 0, 365 377 }, 378 + border_x_0: { 379 + borderLeftWidth: 0, 380 + borderRightWidth: 0, 381 + }, 382 + border_y_0: { 383 + borderTopWidth: 0, 384 + borderBottomWidth: 0, 385 + }, 366 386 border: { 367 387 borderWidth: StyleSheet.hairlineWidth, 368 388 }, ··· 377 397 }, 378 398 border_r: { 379 399 borderRightWidth: StyleSheet.hairlineWidth, 400 + }, 401 + border_x: { 402 + borderLeftWidth: StyleSheet.hairlineWidth, 403 + borderRightWidth: StyleSheet.hairlineWidth, 404 + }, 405 + border_y: { 406 + borderTopWidth: StyleSheet.hairlineWidth, 407 + borderBottomWidth: StyleSheet.hairlineWidth, 380 408 }, 381 409 border_transparent: { 382 410 borderColor: 'transparent', ··· 987 1015 }), 988 1016 block: web({ 989 1017 display: 'block', 1018 + }), 1019 + contents: web({ 1020 + display: 'contents', 990 1021 }), 991 1022 992 1023 /*
+55 -6
src/components/Dialog/index.tsx
··· 12 12 import { 13 13 KeyboardAwareScrollView, 14 14 useKeyboardHandler, 15 + useReanimatedKeyboardAnimation, 15 16 } from 'react-native-keyboard-controller' 16 - import {runOnJS} from 'react-native-reanimated' 17 - import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes' 17 + import Animated, { 18 + runOnJS, 19 + type ScrollEvent, 20 + useAnimatedStyle, 21 + } from 'react-native-reanimated' 18 22 import {useSafeAreaInsets} from 'react-native-safe-area-context' 19 23 import {msg} from '@lingui/macro' 20 24 import {useLingui} from '@lingui/react' ··· 26 30 import {useA11y} from '#/state/a11y' 27 31 import {useDialogStateControlContext} from '#/state/dialogs' 28 32 import {List, type ListMethods, type ListProps} from '#/view/com/util/List' 29 - import {atoms as a, tokens, useTheme} from '#/alf' 33 + import {atoms as a, ios, platform, tokens, useTheme} from '#/alf' 30 34 import {useThemeName} from '#/alf/util/useColorModeTheme' 31 35 import {Context, useDialogContext} from '#/components/Dialog/context' 32 36 import { ··· 256 260 contentContainerStyle, 257 261 ]} 258 262 ref={ref} 263 + showsVerticalScrollIndicator={isAndroid ? false : undefined} 259 264 {...props} 260 265 bounces={nativeSnapPoint === BottomSheetSnapPoint.Full} 261 266 bottomOffset={30} ··· 275 280 ListProps<any> & { 276 281 webInnerStyle?: StyleProp<ViewStyle> 277 282 webInnerContentContainerStyle?: StyleProp<ViewStyle> 283 + footer?: React.ReactNode 278 284 } 279 - >(function InnerFlatList({style, ...props}, ref) { 285 + >(function InnerFlatList({footer, style, ...props}, ref) { 280 286 const insets = useSafeAreaInsets() 281 287 const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() 282 288 283 - const onScroll = (e: ReanimatedScrollEvent) => { 289 + useEnableKeyboardController(isIOS) 290 + 291 + const onScroll = (e: ScrollEvent) => { 284 292 'worklet' 285 293 if (!isAndroid) { 286 294 return ··· 300 308 bounces={nativeSnapPoint === BottomSheetSnapPoint.Full} 301 309 ListFooterComponent={<View style={{height: insets.bottom + 100}} />} 302 310 ref={ref} 311 + showsVerticalScrollIndicator={isAndroid ? false : undefined} 303 312 {...props} 304 - style={[style]} 313 + style={[a.h_full, style]} 305 314 /> 315 + {footer} 306 316 </ScrollProvider> 307 317 ) 308 318 }) 319 + 320 + export function FlatListFooter({children}: {children: React.ReactNode}) { 321 + const t = useTheme() 322 + const {top, bottom} = useSafeAreaInsets() 323 + const {height} = useReanimatedKeyboardAnimation() 324 + 325 + const animatedStyle = useAnimatedStyle(() => { 326 + if (!isIOS) return {} 327 + return { 328 + transform: [{translateY: Math.min(0, height.get() + bottom - 10)}], 329 + } 330 + }) 331 + 332 + return ( 333 + <Animated.View 334 + style={[ 335 + a.absolute, 336 + a.bottom_0, 337 + a.w_full, 338 + a.z_10, 339 + a.border_t, 340 + t.atoms.bg, 341 + t.atoms.border_contrast_low, 342 + a.px_lg, 343 + a.pt_md, 344 + { 345 + paddingBottom: platform({ 346 + ios: tokens.space.md + bottom, 347 + android: tokens.space.md + bottom + top, 348 + }), 349 + }, 350 + // TODO: had to admit defeat here, but we should 351 + // try and get this to work for Android as well -sfn 352 + ios(animatedStyle), 353 + ]}> 354 + {children} 355 + </Animated.View> 356 + ) 357 + } 309 358 310 359 export function Handle({difference = false}: {difference?: boolean}) { 311 360 const t = useTheme()
+35 -3
src/components/Dialog/index.web.tsx
··· 33 33 export * from '#/components/Dialog/utils' 34 34 export {Input} from '#/components/forms/TextField' 35 35 36 + // 100 minus 10vh of paddingVertical 37 + export const WEB_DIALOG_HEIGHT = '80vh' 38 + 36 39 const stopPropagation = (e: any) => e.stopPropagation() 37 40 const preventDefault = (e: any) => e.preventDefault() 38 41 ··· 215 218 FlatListProps<any> & {label: string} & { 216 219 webInnerStyle?: StyleProp<ViewStyle> 217 220 webInnerContentContainerStyle?: StyleProp<ViewStyle> 221 + footer?: React.ReactNode 218 222 } 219 223 >(function InnerFlatList( 220 - {label, style, webInnerStyle, webInnerContentContainerStyle, ...props}, 224 + { 225 + label, 226 + style, 227 + webInnerStyle, 228 + webInnerContentContainerStyle, 229 + footer, 230 + ...props 231 + }, 221 232 ref, 222 233 ) { 223 234 const {gtMobile} = useBreakpoints() ··· 227 238 style={[ 228 239 a.overflow_hidden, 229 240 a.px_0, 230 - // 100 minus 10vh of paddingVertical 231 - web({maxHeight: '80vh'}), 241 + web({maxHeight: WEB_DIALOG_HEIGHT}), 232 242 webInnerStyle, 233 243 ]} 234 244 contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}> ··· 237 247 style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, flatten(style)]} 238 248 {...props} 239 249 /> 250 + {footer} 240 251 </Inner> 241 252 ) 242 253 }) 254 + 255 + export function FlatListFooter({children}: {children: React.ReactNode}) { 256 + const t = useTheme() 257 + 258 + return ( 259 + <View 260 + style={[ 261 + a.absolute, 262 + a.bottom_0, 263 + a.w_full, 264 + a.z_10, 265 + t.atoms.bg, 266 + a.border_t, 267 + t.atoms.border_contrast_low, 268 + a.px_lg, 269 + a.py_md, 270 + ]}> 271 + {children} 272 + </View> 273 + ) 274 + } 243 275 244 276 export function Close() { 245 277 const {_} = useLingui()
+390
src/components/InterestTabs.tsx
··· 1 + import {useEffect, useRef, useState} from 'react' 2 + import { 3 + type ScrollView, 4 + type StyleProp, 5 + View, 6 + type ViewStyle, 7 + } from 'react-native' 8 + import {msg} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + 11 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 12 + import {isWeb} from '#/platform/detection' 13 + import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' 14 + import {atoms as a, tokens, useTheme, web} from '#/alf' 15 + import {transparentifyColor} from '#/alf/util/colorGeneration' 16 + import {Button, ButtonIcon} from '#/components/Button' 17 + import { 18 + ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft, 19 + ArrowRight_Stroke2_Corner0_Rounded as ArrowRight, 20 + } from '#/components/icons/Arrow' 21 + import {Text} from '#/components/Typography' 22 + 23 + /** 24 + * Tab component that automatically scrolls the selected tab into view - used for interests 25 + * in the Find Follows dialog, Explore screen, etc. 26 + */ 27 + export function InterestTabs({ 28 + onSelectTab, 29 + interests, 30 + selectedInterest, 31 + disabled, 32 + interestsDisplayNames, 33 + TabComponent = Tab, 34 + contentContainerStyle, 35 + gutterWidth = tokens.space.lg, 36 + }: { 37 + onSelectTab: (tab: string) => void 38 + interests: string[] 39 + selectedInterest: string 40 + interestsDisplayNames: Record<string, string> 41 + /** still allows changing tab, but removes the active state from the selected tab */ 42 + disabled?: boolean 43 + TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>> 44 + contentContainerStyle?: StyleProp<ViewStyle> 45 + gutterWidth?: number 46 + }) { 47 + const t = useTheme() 48 + const {_} = useLingui() 49 + const listRef = useRef<ScrollView>(null) 50 + const [totalWidth, setTotalWidth] = useState(0) 51 + const [scrollX, setScrollX] = useState(0) 52 + const [contentWidth, setContentWidth] = useState(0) 53 + const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) 54 + const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) 55 + 56 + const onInitialLayout = useNonReactiveCallback(() => { 57 + const index = interests.indexOf(selectedInterest) 58 + scrollIntoViewIfNeeded(index) 59 + }) 60 + 61 + useEffect(() => { 62 + if (tabOffsets) { 63 + onInitialLayout() 64 + } 65 + }, [tabOffsets, onInitialLayout]) 66 + 67 + function scrollIntoViewIfNeeded(index: number) { 68 + const btnLayout = tabOffsets[index] 69 + if (!btnLayout) return 70 + listRef.current?.scrollTo({ 71 + // centered 72 + x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2), 73 + animated: true, 74 + }) 75 + } 76 + 77 + function handleSelectTab(index: number) { 78 + const tab = interests[index] 79 + onSelectTab(tab) 80 + scrollIntoViewIfNeeded(index) 81 + } 82 + 83 + function handleTabLayout(index: number, x: number, width: number) { 84 + if (!tabOffsets.length) { 85 + pendingTabOffsets.current[index] = {x, width} 86 + if (pendingTabOffsets.current.length === interests.length) { 87 + setTabOffsets(pendingTabOffsets.current) 88 + } 89 + } 90 + } 91 + 92 + const canScrollLeft = scrollX > 0 93 + const canScrollRight = scrollX < contentWidth - totalWidth 94 + 95 + const cleanupRef = useRef<(() => void) | null>(null) 96 + 97 + function scrollLeft() { 98 + if (isContinuouslyScrollingRef.current) { 99 + return 100 + } 101 + if (listRef.current && canScrollLeft) { 102 + const newScrollX = Math.max(0, scrollX - 200) 103 + listRef.current.scrollTo({x: newScrollX, animated: true}) 104 + } 105 + } 106 + 107 + function scrollRight() { 108 + if (isContinuouslyScrollingRef.current) { 109 + return 110 + } 111 + if (listRef.current && canScrollRight) { 112 + const maxScroll = contentWidth - totalWidth 113 + const newScrollX = Math.min(maxScroll, scrollX + 200) 114 + listRef.current.scrollTo({x: newScrollX, animated: true}) 115 + } 116 + } 117 + 118 + const isContinuouslyScrollingRef = useRef(false) 119 + 120 + function startContinuousScroll(direction: 'left' | 'right') { 121 + // Clear any existing continuous scroll 122 + if (cleanupRef.current) { 123 + cleanupRef.current() 124 + } 125 + 126 + let holdTimeout: NodeJS.Timeout | null = null 127 + let animationFrame: number | null = null 128 + let isActive = true 129 + isContinuouslyScrollingRef.current = false 130 + 131 + const cleanup = () => { 132 + isActive = false 133 + if (holdTimeout) clearTimeout(holdTimeout) 134 + if (animationFrame) cancelAnimationFrame(animationFrame) 135 + cleanupRef.current = null 136 + // Reset flag after a delay to prevent onPress from firing 137 + setTimeout(() => { 138 + isContinuouslyScrollingRef.current = false 139 + }, 100) 140 + } 141 + 142 + cleanupRef.current = cleanup 143 + 144 + // Start continuous scrolling after hold delay 145 + holdTimeout = setTimeout(() => { 146 + if (!isActive) return 147 + 148 + isContinuouslyScrollingRef.current = true 149 + let currentScrollPosition = scrollX 150 + 151 + const scroll = () => { 152 + if (!isActive || !listRef.current) return 153 + 154 + const scrollAmount = 3 155 + const maxScroll = contentWidth - totalWidth 156 + 157 + let newScrollX: number 158 + let canContinue = false 159 + 160 + if (direction === 'left' && currentScrollPosition > 0) { 161 + newScrollX = Math.max(0, currentScrollPosition - scrollAmount) 162 + canContinue = newScrollX > 0 163 + } else if (direction === 'right' && currentScrollPosition < maxScroll) { 164 + newScrollX = Math.min(maxScroll, currentScrollPosition + scrollAmount) 165 + canContinue = newScrollX < maxScroll 166 + } else { 167 + return 168 + } 169 + 170 + currentScrollPosition = newScrollX 171 + listRef.current.scrollTo({x: newScrollX, animated: false}) 172 + 173 + if (canContinue && isActive) { 174 + animationFrame = requestAnimationFrame(scroll) 175 + } 176 + } 177 + 178 + scroll() 179 + }, 500) 180 + } 181 + 182 + function stopContinuousScroll() { 183 + if (cleanupRef.current) { 184 + cleanupRef.current() 185 + } 186 + } 187 + 188 + useEffect(() => { 189 + return () => { 190 + if (cleanupRef.current) { 191 + cleanupRef.current() 192 + } 193 + } 194 + }, []) 195 + 196 + return ( 197 + <View style={[a.relative, a.flex_row]}> 198 + <DraggableScrollView 199 + ref={listRef} 200 + contentContainerStyle={[ 201 + a.gap_sm, 202 + {paddingHorizontal: gutterWidth}, 203 + contentContainerStyle, 204 + ]} 205 + showsHorizontalScrollIndicator={false} 206 + decelerationRate="fast" 207 + snapToOffsets={ 208 + tabOffsets.length === interests.length 209 + ? tabOffsets.map(o => o.x - tokens.space.xl) 210 + : undefined 211 + } 212 + onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} 213 + onContentSizeChange={width => setContentWidth(width)} 214 + onScroll={evt => { 215 + const newScrollX = evt.nativeEvent.contentOffset.x 216 + setScrollX(newScrollX) 217 + }} 218 + scrollEventThrottle={16}> 219 + {interests.map((interest, i) => { 220 + const active = interest === selectedInterest && !disabled 221 + return ( 222 + <TabComponent 223 + key={interest} 224 + onSelectTab={handleSelectTab} 225 + active={active} 226 + index={i} 227 + interest={interest} 228 + interestsDisplayName={interestsDisplayNames[interest]} 229 + onLayout={handleTabLayout} 230 + /> 231 + ) 232 + })} 233 + </DraggableScrollView> 234 + {isWeb && canScrollLeft && ( 235 + <View 236 + style={[ 237 + a.absolute, 238 + a.top_0, 239 + a.left_0, 240 + a.bottom_0, 241 + a.justify_center, 242 + {paddingLeft: gutterWidth}, 243 + a.pr_md, 244 + a.z_10, 245 + web({ 246 + background: `linear-gradient(to right, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`, 247 + }), 248 + ]}> 249 + <Button 250 + label={_(msg`Scroll left`)} 251 + onPress={scrollLeft} 252 + onPressIn={() => startContinuousScroll('left')} 253 + onPressOut={stopContinuousScroll} 254 + color="secondary" 255 + size="small" 256 + style={[ 257 + a.border, 258 + t.atoms.border_contrast_low, 259 + t.atoms.bg, 260 + a.h_full, 261 + {aspectRatio: 1}, 262 + a.rounded_full, 263 + ]}> 264 + <ButtonIcon icon={ArrowLeft} /> 265 + </Button> 266 + </View> 267 + )} 268 + {isWeb && canScrollRight && ( 269 + <View 270 + style={[ 271 + a.absolute, 272 + a.top_0, 273 + a.right_0, 274 + a.bottom_0, 275 + a.justify_center, 276 + {paddingRight: gutterWidth}, 277 + a.pl_md, 278 + a.z_10, 279 + web({ 280 + background: `linear-gradient(to left, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`, 281 + }), 282 + ]}> 283 + <Button 284 + label={_(msg`Scroll right`)} 285 + onPress={scrollRight} 286 + onPressIn={() => startContinuousScroll('right')} 287 + onPressOut={stopContinuousScroll} 288 + color="secondary" 289 + size="small" 290 + style={[ 291 + a.border, 292 + t.atoms.border_contrast_low, 293 + t.atoms.bg, 294 + a.h_full, 295 + {aspectRatio: 1}, 296 + a.rounded_full, 297 + ]}> 298 + <ButtonIcon icon={ArrowRight} /> 299 + </Button> 300 + </View> 301 + )} 302 + </View> 303 + ) 304 + } 305 + 306 + function Tab({ 307 + onSelectTab, 308 + interest, 309 + active, 310 + index, 311 + interestsDisplayName, 312 + onLayout, 313 + }: { 314 + onSelectTab: (index: number) => void 315 + interest: string 316 + active: boolean 317 + index: number 318 + interestsDisplayName: string 319 + onLayout: (index: number, x: number, width: number) => void 320 + }) { 321 + const t = useTheme() 322 + const {_} = useLingui() 323 + const label = active 324 + ? _( 325 + msg({ 326 + message: `"${interestsDisplayName}" category (active)`, 327 + comment: 328 + 'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is currently selected.', 329 + }), 330 + ) 331 + : _( 332 + msg({ 333 + message: `Select "${interestsDisplayName}" category`, 334 + comment: 335 + 'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is not currently active and can be selected.', 336 + }), 337 + ) 338 + 339 + return ( 340 + <View 341 + key={interest} 342 + onLayout={e => 343 + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 344 + }> 345 + <Button 346 + label={label} 347 + onPress={() => onSelectTab(index)} 348 + // disable focus ring, we handle it 349 + style={web({outline: 'none'})}> 350 + {({hovered, pressed, focused}) => ( 351 + <View 352 + style={[ 353 + a.rounded_full, 354 + a.px_lg, 355 + a.py_sm, 356 + a.border, 357 + active || hovered || pressed 358 + ? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium] 359 + : focused 360 + ? { 361 + borderColor: t.palette.primary_300, 362 + backgroundColor: t.palette.primary_25, 363 + } 364 + : [t.atoms.bg, t.atoms.border_contrast_low], 365 + ]}> 366 + <Text 367 + style={[ 368 + a.font_medium, 369 + active || hovered || pressed 370 + ? t.atoms.text 371 + : t.atoms.text_contrast_medium, 372 + ]}> 373 + {interestsDisplayName} 374 + </Text> 375 + </View> 376 + )} 377 + </Button> 378 + </View> 379 + ) 380 + } 381 + 382 + export function boostInterests(boosts?: string[]) { 383 + return (_a: string, _b: string) => { 384 + const indexA = boosts?.indexOf(_a) ?? -1 385 + const indexB = boosts?.indexOf(_b) ?? -1 386 + const rankA = indexA === -1 ? Infinity : indexA 387 + const rankB = indexB === -1 ? Infinity : indexB 388 + return rankA - rankB 389 + } 390 + }
+24 -122
src/components/ProgressGuide/FollowDialog.tsx
··· 1 1 import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 - import { 3 - ScrollView, 4 - type StyleProp, 5 - TextInput, 6 - useWindowDimensions, 7 - View, 8 - type ViewStyle, 9 - } from 'react-native' 2 + import {TextInput, useWindowDimensions, View} from 'react-native' 10 3 import {type ModerationOpts} from '@atproto/api' 11 4 import {msg, Trans} from '@lingui/macro' 12 5 import {useLingui} from '@lingui/react' 13 6 14 - import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 15 7 import {logEvent} from '#/lib/statsig/statsig' 16 8 import {isWeb} from '#/platform/detection' 17 9 import {useModerationOpts} from '#/state/preferences/moderation-opts' ··· 28 20 import { 29 21 atoms as a, 30 22 native, 31 - tokens, 32 23 useBreakpoints, 33 24 useTheme, 34 25 type ViewStyleProp, ··· 40 31 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' 41 32 import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 42 33 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 34 + import {boostInterests, InterestTabs} from '#/components/InterestTabs' 43 35 import * as ProfileCard from '#/components/ProfileCard' 44 36 import {Text} from '#/components/Typography' 45 37 import type * as bsky from '#/types/bsky' ··· 337 329 }} 338 330 onEscape={control.close} 339 331 /> 340 - <Tabs 332 + <InterestTabs 341 333 onSelectTab={onSelectTab} 342 334 interests={interests} 343 335 selectedInterest={selectedInterest} 344 - hasSearchText={!!searchText} 336 + disabled={!!searchText} 345 337 interestsDisplayNames={interestsDisplayNames} 338 + TabComponent={Tab} 346 339 /> 347 340 </View> 348 341 </View> ··· 403 396 ) 404 397 } 405 398 406 - let Tabs = ({ 407 - onSelectTab, 408 - interests, 409 - selectedInterest, 410 - hasSearchText, 411 - interestsDisplayNames, 412 - TabComponent = Tab, 413 - contentContainerStyle, 414 - }: { 415 - onSelectTab: (tab: string) => void 416 - interests: string[] 417 - selectedInterest: string 418 - hasSearchText: boolean 419 - interestsDisplayNames: Record<string, string> 420 - TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>> 421 - contentContainerStyle?: StyleProp<ViewStyle> 422 - }): React.ReactNode => { 423 - const listRef = useRef<ScrollView>(null) 424 - const [totalWidth, setTotalWidth] = useState(0) 425 - const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) 426 - const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) 427 - 428 - const onInitialLayout = useNonReactiveCallback(() => { 429 - const index = interests.indexOf(selectedInterest) 430 - scrollIntoViewIfNeeded(index) 431 - }) 432 - 433 - useEffect(() => { 434 - if (tabOffsets) { 435 - onInitialLayout() 436 - } 437 - }, [tabOffsets, onInitialLayout]) 438 - 439 - function scrollIntoViewIfNeeded(index: number) { 440 - const btnLayout = tabOffsets[index] 441 - if (!btnLayout) return 442 - listRef.current?.scrollTo({ 443 - // centered 444 - x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2), 445 - animated: true, 446 - }) 447 - } 448 - 449 - function handleSelectTab(index: number) { 450 - const tab = interests[index] 451 - onSelectTab(tab) 452 - scrollIntoViewIfNeeded(index) 453 - } 454 - 455 - function handleTabLayout(index: number, x: number, width: number) { 456 - if (!tabOffsets.length) { 457 - pendingTabOffsets.current[index] = {x, width} 458 - if (pendingTabOffsets.current.length === interests.length) { 459 - setTabOffsets(pendingTabOffsets.current) 460 - } 461 - } 462 - } 463 - 464 - return ( 465 - <ScrollView 466 - ref={listRef} 467 - horizontal 468 - contentContainerStyle={[a.gap_sm, a.px_lg, contentContainerStyle]} 469 - showsHorizontalScrollIndicator={false} 470 - decelerationRate="fast" 471 - snapToOffsets={ 472 - tabOffsets.length === interests.length 473 - ? tabOffsets.map(o => o.x - tokens.space.xl) 474 - : undefined 475 - } 476 - onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} 477 - scrollEventThrottle={200} // big throttle 478 - > 479 - {interests.map((interest, i) => { 480 - const active = interest === selectedInterest && !hasSearchText 481 - return ( 482 - <TabComponent 483 - key={interest} 484 - onSelectTab={handleSelectTab} 485 - active={active} 486 - index={i} 487 - interest={interest} 488 - interestsDisplayName={interestsDisplayNames[interest]} 489 - onLayout={handleTabLayout} 490 - /> 491 - ) 492 - })} 493 - </ScrollView> 494 - ) 495 - } 496 - Tabs = memo(Tabs) 497 - export {Tabs} 498 - 499 399 let Tab = ({ 500 400 onSelectTab, 501 401 interest, ··· 513 413 }): React.ReactNode => { 514 414 const t = useTheme() 515 415 const {_} = useLingui() 516 - const activeText = active ? _(msg` (active)`) : '' 416 + const label = active 417 + ? _( 418 + msg({ 419 + message: `Search for "${interestsDisplayName}" (active)`, 420 + comment: 421 + 'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected.', 422 + }), 423 + ) 424 + : _( 425 + msg({ 426 + message: `Search for "${interestsDisplayName}"`, 427 + comment: 428 + 'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected.', 429 + }), 430 + ) 517 431 return ( 518 432 <View 519 433 key={interest} 520 434 onLayout={e => 521 435 onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 522 436 }> 523 - <Button 524 - label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} 525 - onPress={() => onSelectTab(index)}> 526 - {({hovered, pressed, focused}) => ( 437 + <Button label={label} onPress={() => onSelectTab(index)}> 438 + {({hovered, pressed}) => ( 527 439 <View 528 440 style={[ 529 441 a.rounded_full, 530 442 a.px_lg, 531 443 a.py_sm, 532 444 a.border, 533 - active || hovered || pressed || focused 445 + active || hovered || pressed 534 446 ? [ 535 447 t.atoms.bg_contrast_25, 536 448 {borderColor: t.atoms.bg_contrast_25.backgroundColor}, ··· 540 452 <Text 541 453 style={[ 542 454 a.font_medium, 543 - active || hovered || pressed || focused 455 + active || hovered || pressed 544 456 ? t.atoms.text 545 457 : t.atoms.text_contrast_medium, 546 458 ]}> ··· 759 671 </View> 760 672 ) 761 673 } 762 - 763 - export function boostInterests(boosts?: string[]) { 764 - return (_a: string, _b: string) => { 765 - const indexA = boosts?.indexOf(_a) ?? -1 766 - const indexB = boosts?.indexOf(_b) ?? -1 767 - const rankA = indexA === -1 ? Infinity : indexA 768 - const rankB = indexB === -1 ? Infinity : indexB 769 - return rankA - rankB 770 - } 771 - }
+63 -28
src/components/dialogs/BirthDateSettings.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import {cleanError} from '#/lib/strings/errors' 7 - import {getDateAgo} from '#/lib/strings/time' 7 + import {getAge, getDateAgo} from '#/lib/strings/time' 8 8 import {logger} from '#/logger' 9 9 import {isIOS, isWeb} from '#/platform/detection' 10 10 import { 11 11 usePreferencesQuery, 12 - UsePreferencesQueryResponse, 12 + type UsePreferencesQueryResponse, 13 13 usePreferencesSetBirthDateMutation, 14 14 } from '#/state/queries/preferences' 15 15 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 16 - import {atoms as a, useTheme} from '#/alf' 16 + import {atoms as a, useTheme, web} from '#/alf' 17 + import {Admonition} from '#/components/Admonition' 18 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 17 19 import * as Dialog from '#/components/Dialog' 18 20 import {DateField} from '#/components/forms/DateField' 21 + import {InlineLinkText} from '#/components/Link' 19 22 import {Loader} from '#/components/Loader' 20 23 import {Text} from '#/components/Typography' 21 - import {Button, ButtonIcon, ButtonText} from '../Button' 22 24 23 25 export function BirthDateSettingsDialog({ 24 26 control, ··· 32 34 return ( 33 35 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 34 36 <Dialog.Handle /> 35 - <Dialog.ScrollableInner label={_(msg`My Birthday`)}> 36 - <View style={[a.gap_sm, a.pb_lg]}> 37 - <Text style={[a.text_2xl, a.font_bold]}> 37 + <Dialog.ScrollableInner 38 + label={_(msg`My Birthday`)} 39 + style={web({maxWidth: 400})}> 40 + <View style={[a.gap_sm]}> 41 + <Text style={[a.text_xl, a.font_bold]}> 38 42 <Trans>My Birthday</Trans> 39 43 </Text> 40 - <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 41 - <Trans>This information is not shared with other users.</Trans> 44 + <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}> 45 + <Trans> 46 + This information is private and not shared with other users. 47 + </Trans> 42 48 </Text> 49 + 50 + {isLoading ? ( 51 + <Loader size="xl" /> 52 + ) : error || !preferences ? ( 53 + <ErrorMessage 54 + message={ 55 + error?.toString() || 56 + _( 57 + msg`We were unable to load your birth date preferences. Please try again.`, 58 + ) 59 + } 60 + style={[a.rounded_sm]} 61 + /> 62 + ) : ( 63 + <BirthdayInner control={control} preferences={preferences} /> 64 + )} 43 65 </View> 44 66 45 - {isLoading ? ( 46 - <Loader size="xl" /> 47 - ) : error || !preferences ? ( 48 - <ErrorMessage 49 - message={ 50 - error?.toString() || 51 - _( 52 - msg`We were unable to load your birth date preferences. Please try again.`, 53 - ) 54 - } 55 - style={[a.rounded_sm]} 56 - /> 57 - ) : ( 58 - <BirthdayInner control={control} preferences={preferences} /> 59 - )} 60 - 61 67 <Dialog.Close /> 62 68 </Dialog.ScrollableInner> 63 69 </Dialog.Outer> ··· 72 78 preferences: UsePreferencesQueryResponse 73 79 }) { 74 80 const {_} = useLingui() 75 - const [date, setDate] = React.useState(preferences.birthDate || new Date()) 81 + const [date, setDate] = React.useState( 82 + preferences.birthDate || getDateAgo(18), 83 + ) 76 84 const { 77 85 isPending, 78 86 isError, ··· 80 88 mutateAsync: setBirthDate, 81 89 } = usePreferencesSetBirthDateMutation() 82 90 const hasChanged = date !== preferences.birthDate 91 + 92 + const age = getAge(new Date(date)) 93 + const isUnder13 = age < 13 94 + const isUnder18 = age >= 13 && age < 18 83 95 84 96 const onSave = React.useCallback(async () => { 85 97 try { ··· 102 114 onChangeDate={newDate => setDate(new Date(newDate))} 103 115 label={_(msg`Birthday`)} 104 116 accessibilityHint={_(msg`Enter your birth date`)} 105 - maximumDate={getDateAgo(13)} 106 117 /> 107 118 </View> 108 119 120 + {isUnder18 && hasChanged && ( 121 + <Admonition type="info"> 122 + <Trans> 123 + The birthdate you've entered means you are under 18 years old. 124 + Certain content and features may be unavailable to you. 125 + </Trans> 126 + </Admonition> 127 + )} 128 + 129 + {isUnder13 && ( 130 + <Admonition type="error"> 131 + <Trans> 132 + You must be at least 13 years old to use Bluesky. Read our{' '} 133 + <InlineLinkText 134 + to="https://bsky.social/about/support/tos" 135 + label={_(msg`Terms of Service`)}> 136 + Terms of Service 137 + </InlineLinkText>{' '} 138 + for more information. 139 + </Trans> 140 + </Admonition> 141 + )} 142 + 109 143 {isError ? ( 110 144 <ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} /> 111 145 ) : undefined} ··· 116 150 size="large" 117 151 onPress={onSave} 118 152 variant="solid" 119 - color="primary"> 153 + color="primary" 154 + disabled={isUnder13}> 120 155 <ButtonText> 121 156 {hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>} 122 157 </ButtonText>
+21 -25
src/components/dialogs/StarterPackDialog.tsx
··· 1 - import React from 'react' 1 + import {useCallback, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import { 4 4 type AppBskyGraphGetStarterPacksWithMembership, ··· 22 22 } from '#/state/queries/list-memberships' 23 23 import * as Toast from '#/view/com/util/Toast' 24 24 import {atoms as a, useTheme} from '#/alf' 25 + import {AvatarStack} from '#/components/AvatarStack' 25 26 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 26 27 import * as Dialog from '#/components/Dialog' 27 28 import {Divider} from '#/components/Divider' 29 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 30 + import {StarterPack} from '#/components/icons/StarterPack' 31 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 28 32 import {Loader} from '#/components/Loader' 29 33 import {Text} from '#/components/Typography' 30 34 import * as bsky from '#/types/bsky' 31 - import {AvatarStack} from '../AvatarStack' 32 - import {PlusLarge_Stroke2_Corner0_Rounded} from '../icons/Plus' 33 - import {StarterPack} from '../icons/StarterPack' 34 - import {TimesLarge_Stroke2_Corner0_Rounded} from '../icons/Times' 35 35 36 36 type StarterPackWithMembership = 37 37 AppBskyGraphGetStarterPacksWithMembership.StarterPackWithMembership ··· 51 51 const navigation = useNavigation<NavigationProp>() 52 52 const requireEmailVerification = useRequireEmailVerification() 53 53 54 - const navToWizard = React.useCallback(() => { 54 + const navToWizard = useCallback(() => { 55 55 control.close() 56 56 navigation.navigate('StarterPackWizard', { 57 57 fromDialog: true, ··· 91 91 const {_} = useLingui() 92 92 const t = useTheme() 93 93 94 - isWeb 95 94 return ( 96 95 <View style={[a.gap_2xl, {paddingTop: isWeb ? 100 : 64}]}> 97 96 <View style={[a.gap_xs, a.align_center]}> ··· 115 114 Create 116 115 </Trans> 117 116 </ButtonText> 118 - <ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} /> 117 + <ButtonIcon icon={PlusIcon} /> 119 118 </Button> 120 119 </View> 121 120 </View> ··· 134 133 enabled?: boolean 135 134 }) { 136 135 const {_} = useLingui() 137 - const t = useTheme() 138 136 139 137 const { 140 138 data, ··· 149 147 const membershipItems = 150 148 data?.pages.flatMap(page => page.starterPacksWithMembership) || [] 151 149 152 - const _onRefresh = React.useCallback(async () => { 150 + const _onRefresh = useCallback(async () => { 153 151 try { 154 152 await refetch() 155 153 } catch (err) { ··· 157 155 } 158 156 }, [refetch]) 159 157 160 - const _onEndReached = React.useCallback(async () => { 158 + const _onEndReached = useCallback(async () => { 161 159 if (isFetchingNextPage || !hasNextPage || isError) return 162 160 try { 163 161 await fetchNextPage() ··· 166 164 } 167 165 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 168 166 169 - const renderItem = React.useCallback( 167 + const renderItem = useCallback( 170 168 ({item}: {item: StarterPackWithMembership}) => ( 171 169 <StarterPackItem starterPackWithMembership={item} targetDid={targetDid} /> 172 170 ), 173 171 [targetDid], 174 172 ) 175 173 176 - const onClose = React.useCallback(() => { 174 + const onClose = useCallback(() => { 177 175 control.close() 178 176 }, [control]) 179 - 180 - const XIcon = React.useMemo(() => { 181 - return ( 182 - <TimesLarge_Stroke2_Corner0_Rounded 183 - fill={t.atoms.text_contrast_medium.color} 184 - /> 185 - ) 186 - }, [t]) 187 177 188 178 const listHeader = ( 189 179 <> ··· 196 186 <Text style={[a.text_lg, a.font_bold]}> 197 187 <Trans>Add to starter packs</Trans> 198 188 </Text> 199 - <Button label={_(msg`Close`)} onPress={onClose}> 200 - <ButtonIcon icon={() => XIcon} /> 189 + <Button 190 + label={_(msg`Close`)} 191 + onPress={onClose} 192 + variant="ghost" 193 + color="secondary" 194 + size="small" 195 + shape="round"> 196 + <ButtonIcon icon={XIcon} /> 201 197 </Button> 202 198 </View> 203 199 {membershipItems.length > 0 && ( ··· 217 213 Create 218 214 </Trans> 219 215 </ButtonText> 220 - <ButtonIcon icon={PlusLarge_Stroke2_Corner0_Rounded} /> 216 + <ButtonIcon icon={PlusIcon} /> 221 217 </Button> 222 218 </View> 223 219 <Divider /> ··· 268 264 const starterPack = starterPackWithMembership.starterPack 269 265 const isInPack = !!starterPackWithMembership.listItem 270 266 271 - const [isPendingRefresh, setIsPendingRefresh] = React.useState(false) 267 + const [isPendingRefresh, setIsPendingRefresh] = useState(false) 272 268 273 269 const {mutate: addMembership} = useListMembershipAddMutation({ 274 270 onSuccess: () => {
+4 -2
src/components/forms/Toggle.tsx
··· 1 1 import React from 'react' 2 - import {Pressable, View, type ViewStyle} from 'react-native' 2 + import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' 3 3 import Animated, {LinearTransition} from 'react-native-reanimated' 4 4 5 5 import {HITSLOP_10} from '#/lib/constants' ··· 59 59 disabled?: boolean 60 60 onChange: (value: string[]) => void 61 61 label: string 62 + style?: StyleProp<ViewStyle> 62 63 }> 63 64 64 65 export type ItemProps = ViewStyleProp & { ··· 84 85 type = 'checkbox', 85 86 maxSelections, 86 87 label, 88 + style, 87 89 }: GroupProps) { 88 90 const groupRole = type === 'radio' ? 'radiogroup' : undefined 89 91 const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues ··· 136 138 return ( 137 139 <GroupContext.Provider value={context}> 138 140 <View 139 - style={[a.w_full]} 141 + style={[a.w_full, style]} 140 142 role={groupRole} 141 143 {...(groupRole === 'radiogroup' 142 144 ? {
+1 -1
src/components/moderation/ContentHider.tsx
··· 215 215 control.open() 216 216 }} 217 217 label={_( 218 - msg`Learn more about the moderation applied to this content.`, 218 + msg`Learn more about the moderation applied to this content`, 219 219 )} 220 220 style={[a.pt_sm]}> 221 221 {state => (
+7 -2
src/components/verification/VerificationsDialog.tsx
··· 147 147 <Link 148 148 overridePresentation 149 149 to={urls.website.blog.initialVerificationAnnouncement} 150 - label={_(msg`Learn more about verification on Bluesky`)} 150 + label={_( 151 + msg({ 152 + message: `Learn more about verification on Bluesky`, 153 + context: `english-only-resource`, 154 + }), 155 + )} 151 156 size="small" 152 157 variant="solid" 153 158 color="secondary" ··· 162 167 ) 163 168 }}> 164 169 <ButtonText> 165 - <Trans>Learn more</Trans> 170 + <Trans context="english-only-resource">Learn more</Trans> 166 171 </ButtonText> 167 172 </Link> 168 173 </View>
+7 -2
src/components/verification/VerifierDialog.tsx
··· 114 114 <Link 115 115 overridePresentation 116 116 to={urls.website.blog.initialVerificationAnnouncement} 117 - label={_(msg`Learn more about verification on Bluesky`)} 117 + label={_( 118 + msg({ 119 + message: `Learn more about verification on Bluesky`, 120 + context: `english-only-resource`, 121 + }), 122 + )} 118 123 size="small" 119 124 variant="solid" 120 125 color="primary" ··· 129 134 ) 130 135 }}> 131 136 <ButtonText> 132 - <Trans>Learn more</Trans> 137 + <Trans context="english-only-resource">Learn more</Trans> 133 138 </ButtonText> 134 139 </Link> 135 140 <Button
+3 -3
src/lib/hooks/useDraggableScrollView.ts
··· 20 20 return 21 21 } 22 22 const slider = ref.current as unknown as HTMLDivElement 23 - if (!slider) { 24 - return 25 - } 26 23 let isDragging = false 27 24 let isMouseDown = false 28 25 let startX = 0 ··· 61 58 e.preventDefault() 62 59 const walk = x - startX 63 60 slider.scrollLeft = scrollLeft - walk 61 + 62 + if (slider.contains(document.activeElement)) 63 + (document.activeElement as HTMLElement)?.blur?.() 64 64 } 65 65 66 66 slider.addEventListener('mousedown', mouseDown)
+3 -1
src/lib/icons.tsx
··· 7 7 style, 8 8 size, 9 9 strokeWidth = 2, 10 + color = 'currentColor', 10 11 }: { 11 12 style?: StyleProp<ViewStyle> 12 13 size?: string | number 13 14 strokeWidth?: number 15 + color?: string 14 16 }) { 15 17 return ( 16 18 <Svg 17 19 fill="none" 18 20 viewBox="0 0 24 24" 19 21 strokeWidth={strokeWidth} 20 - stroke="currentColor" 22 + stroke={color} 21 23 width={size || 24} 22 24 height={size || 24} 23 25 style={style}>
+2
src/lib/statsig/gates.ts
··· 9 9 | 'explore_show_suggested_feeds' 10 10 | 'old_postonboarding' 11 11 | 'onboarding_add_video_feed' 12 + | 'onboarding_suggested_accounts' 13 + | 'onboarding_value_prop' 12 14 | 'post_follow_profile_suggested_accounts' 13 15 | 'remove_show_latest_button' 14 16 | 'test_gate_1'
+328 -221
src/locale/locales/en/messages.po
··· 13 13 "Language-Team: \n" 14 14 "Plural-Forms: \n" 15 15 16 - #: src/components/ProgressGuide/FollowDialog.tsx:516 17 - #: src/screens/Search/modules/ExploreSuggestedAccounts.tsx:127 18 - msgid "(active)" 16 + #. Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is currently selected. 17 + #: src/components/InterestTabs.tsx:325 18 + msgid "\"{interestsDisplayName}\" category (active)" 19 19 msgstr "" 20 20 21 21 #: src/screens/Messages/components/ChatListItem.tsx:160 ··· 120 120 msgid "{0, plural, other {# people have}} used this starter pack!" 121 121 msgstr "" 122 122 123 - #: src/components/dialogs/StarterPackDialog.tsx:374 123 + #: src/components/dialogs/StarterPackDialog.tsx:370 124 124 msgid "{0, plural, other {+# more}}" 125 125 msgstr "" 126 126 ··· 216 216 msgid "{count, plural, one {# unread item} other {# unread items}}" 217 217 msgstr "" 218 218 219 - #: src/screens/Profile/Header/EditProfileDialog.tsx:380 219 + #: src/screens/Profile/Header/EditProfileDialog.tsx:385 220 220 msgid "{DESCRIPTION_MAX_GRAPHEMES, plural, other {Description is too long. The maximum number of characters is #.}}" 221 221 msgstr "" 222 222 ··· 517 517 msgid "7 days" 518 518 msgstr "" 519 519 520 + #: src/screens/Onboarding/StepFinished.tsx:341 521 + msgid "A collection of popular feeds you can find on Bluesky, including News, Booksky, Game Dev, Blacksky, and Fountain Pens" 522 + msgstr "" 523 + 520 524 #. If last message does not contain text, fall back to "{user} reacted to {a message}" 521 525 #: src/screens/Messages/components/ChatListItem.tsx:210 522 526 msgid "a message" ··· 641 645 642 646 #: src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx:169 643 647 #: src/components/dialogs/MutedWords.tsx:328 644 - #: src/components/dialogs/StarterPackDialog.tsx:388 645 - #: src/components/dialogs/StarterPackDialog.tsx:394 648 + #: src/components/dialogs/StarterPackDialog.tsx:384 649 + #: src/components/dialogs/StarterPackDialog.tsx:390 646 650 #: src/view/com/modals/UserAddRemoveLists.tsx:235 647 651 msgid "Add" 648 652 msgstr "" ··· 760 764 msgid "Add the default feed of only people you follow" 761 765 msgstr "" 762 766 763 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:406 767 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:416 764 768 msgid "Add the following DNS record to your domain:" 765 769 msgstr "" 766 770 ··· 773 777 msgid "Add to lists" 774 778 msgstr "" 775 779 776 - #: src/components/dialogs/StarterPackDialog.tsx:197 780 + #: src/components/dialogs/StarterPackDialog.tsx:187 777 781 #: src/view/com/profile/ProfileMenu.tsx:308 778 782 #: src/view/com/profile/ProfileMenu.tsx:311 779 783 msgid "Add to starter packs" ··· 788 792 msgid "Added to list" 789 793 msgstr "" 790 794 791 - #: src/components/dialogs/StarterPackDialog.tsx:275 795 + #: src/components/dialogs/StarterPackDialog.tsx:271 792 796 msgid "Added to starter pack" 793 797 msgstr "" 794 798 ··· 849 853 msgid "alice@example.com" 850 854 msgstr "" 851 855 856 + #. the default tab in the interests tab bar 857 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:154 852 858 #: src/view/screens/Notifications.tsx:88 853 859 msgid "All" 854 860 msgstr "" ··· 860 866 #: src/screens/Search/components/SearchLanguageDropdown.tsx:64 861 867 #: src/screens/Search/components/SearchLanguageDropdown.tsx:99 862 868 #: src/screens/Search/components/SearchLanguageDropdown.tsx:101 869 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:280 863 870 msgid "All languages" 864 871 msgstr "" 865 872 ··· 938 945 msgstr "" 939 946 940 947 #: src/components/dialogs/GifSelect.tsx:264 948 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:362 941 949 msgid "An error has occurred" 942 950 msgstr "" 943 951 ··· 947 955 948 956 #: src/view/com/composer/state/video.ts:399 949 957 msgid "An error occurred while compressing the video." 958 + msgstr "" 959 + 960 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:176 961 + msgid "An error occurred while fetching suggested accounts." 950 962 msgstr "" 951 963 952 964 #: src/state/queries/explore-feed-previews.tsx:173 ··· 978 990 msgid "An error occurred while uploading the video." 979 991 msgstr "" 980 992 993 + #: src/screens/Onboarding/StepFinished.tsx:359 994 + msgid "An illustration of several Bluesky posts alongside repost, like, and comment icons" 995 + msgstr "" 996 + 981 997 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:84 982 998 #: src/components/verification/VerifierDialog.tsx:86 983 999 msgid "An illustration showing that Bluesky selects trusted verifiers, and trusted verifiers in turn verify individual user accounts." ··· 1009 1025 msgid "An mockup of a iPhone showing the Bluesky app open to the profile of a verified user with a blue checkmark next to their display name." 1010 1026 msgstr "" 1011 1027 1012 - #: src/screens/Onboarding/StepInterests/index.tsx:185 1028 + #: src/screens/Onboarding/StepInterests/index.tsx:194 1013 1029 msgid "an unknown error occurred" 1014 1030 msgstr "" 1015 1031 ··· 1022 1038 msgid "and" 1023 1039 msgstr "" 1024 1040 1025 - #: src/screens/Onboarding/index.tsx:29 1026 - #: src/screens/Onboarding/state.ts:97 1041 + #: src/screens/Onboarding/index.tsx:43 1042 + #: src/screens/Onboarding/state.ts:102 1027 1043 msgid "Animals" 1028 1044 msgstr "" 1029 1045 ··· 1198 1214 msgid "Are you writing in <0>{suggestedLanguageName}</0>?" 1199 1215 msgstr "" 1200 1216 1201 - #: src/screens/Onboarding/index.tsx:23 1202 - #: src/screens/Onboarding/state.ts:98 1217 + #: src/screens/Onboarding/index.tsx:37 1218 + #: src/screens/Onboarding/state.ts:103 1203 1219 msgid "Art" 1204 1220 msgstr "" 1205 1221 ··· 1301 1317 msgid "Begin the age assurance process by completing the fields below." 1302 1318 msgstr "" 1303 1319 1304 - #: src/components/dialogs/BirthDateSettings.tsx:103 1320 + #: src/components/dialogs/BirthDateSettings.tsx:115 1305 1321 #: src/screens/Settings/AccountSettings.tsx:142 1306 1322 msgid "Birthday" 1307 1323 msgstr "" ··· 1466 1482 msgid "Blur images and filter from feeds" 1467 1483 msgstr "" 1468 1484 1469 - #: src/screens/Onboarding/index.tsx:30 1470 - #: src/screens/Onboarding/state.ts:99 1485 + #: src/screens/Onboarding/index.tsx:44 1486 + #: src/screens/Onboarding/state.ts:104 1471 1487 msgid "Books" 1472 1488 msgstr "" 1473 1489 ··· 1769 1785 msgid "Check your inbox for an email with the confirmation code to enter below:" 1770 1786 msgstr "" 1771 1787 1772 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:389 1788 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:399 1773 1789 msgid "Choose domain verification method" 1774 1790 msgstr "" 1775 1791 ··· 1785 1801 msgid "Choose People" 1786 1802 msgstr "" 1787 1803 1788 - #: src/screens/Onboarding/StepFinished.tsx:298 1804 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:226 1805 + msgid "Choose Post Languages" 1806 + msgstr "" 1807 + 1808 + #: src/screens/Onboarding/StepFinished.tsx:575 1789 1809 msgid "Choose the algorithms that power your custom feeds." 1790 1810 msgstr "" 1791 1811 ··· 1858 1878 msgid "Click to retry failed message" 1859 1879 msgstr "" 1860 1880 1861 - #: src/screens/Onboarding/index.tsx:32 1881 + #: src/screens/Onboarding/index.tsx:46 1862 1882 msgid "Climate" 1863 1883 msgstr "" 1864 1884 ··· 1876 1896 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:178 1877 1897 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:187 1878 1898 #: src/components/dialogs/SearchablePeopleList.tsx:295 1879 - #: src/components/dialogs/StarterPackDialog.tsx:199 1899 + #: src/components/dialogs/StarterPackDialog.tsx:190 1880 1900 #: src/components/dms/EmojiPopup.android.tsx:58 1881 1901 #: src/components/dms/ReportDialog.tsx:381 1882 1902 #: src/components/dms/ReportDialog.tsx:390 ··· 1885 1905 #: src/components/NewskieDialog.tsx:146 1886 1906 #: src/components/NewskieDialog.tsx:153 1887 1907 #: src/components/Post/Embed/ExternalEmbed/Gif.tsx:197 1888 - #: src/components/ProgressGuide/FollowDialog.tsx:386 1908 + #: src/components/ProgressGuide/FollowDialog.tsx:379 1889 1909 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:118 1890 1910 #: src/components/StarterPack/Wizard/WizardEditListDialog.tsx:124 1891 1911 #: src/components/verification/VerificationsDialog.tsx:144 1892 - #: src/components/verification/VerifierDialog.tsx:144 1912 + #: src/components/verification/VerifierDialog.tsx:149 1893 1913 #: src/components/WhoCanReply.tsx:202 1894 1914 #: src/components/WhoCanReply.tsx:209 1895 1915 #: src/screens/Settings/components/ChangePasswordDialog.tsx:286 1896 1916 #: src/screens/Settings/components/ChangePasswordDialog.tsx:291 1917 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:377 1897 1918 #: src/view/com/feeds/MissingFeed.tsx:208 1898 1919 #: src/view/com/feeds/MissingFeed.tsx:215 1899 1920 msgid "Close" 1900 1921 msgstr "" 1901 1922 1902 - #: src/components/Dialog/index.web.tsx:115 1903 - #: src/components/Dialog/index.web.tsx:263 1923 + #: src/components/Dialog/index.web.tsx:118 1924 + #: src/components/Dialog/index.web.tsx:295 1904 1925 msgid "Close active dialog" 1905 1926 msgstr "" 1906 1927 ··· 1916 1937 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:230 1917 1938 #: src/components/dialogs/GifSelect.tsx:274 1918 1939 #: src/components/verification/VerificationsDialog.tsx:136 1919 - #: src/components/verification/VerifierDialog.tsx:136 1940 + #: src/components/verification/VerifierDialog.tsx:141 1941 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:246 1942 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:340 1943 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:372 1920 1944 msgid "Close dialog" 1921 1945 msgstr "" 1922 1946 ··· 1985 2009 msgid "Color theme" 1986 2010 msgstr "" 1987 2011 1988 - #: src/screens/Onboarding/index.tsx:38 1989 - #: src/screens/Onboarding/state.ts:100 2012 + #: src/screens/Onboarding/index.tsx:52 2013 + #: src/screens/Onboarding/state.ts:105 1990 2014 msgid "Comedy" 1991 2015 msgstr "" 1992 2016 1993 - #: src/screens/Onboarding/index.tsx:24 1994 - #: src/screens/Onboarding/state.ts:101 2017 + #: src/screens/Onboarding/index.tsx:38 2018 + #: src/screens/Onboarding/state.ts:106 1995 2019 msgid "Comics" 1996 2020 msgstr "" 1997 2021 ··· 2001 2025 msgid "Community Guidelines" 2002 2026 msgstr "" 2003 2027 2004 - #: src/screens/Onboarding/StepFinished.tsx:311 2028 + #: src/screens/Onboarding/StepFinished.tsx:473 2029 + #: src/screens/Onboarding/StepFinished.tsx:587 2005 2030 msgid "Complete onboarding and start using your account" 2006 2031 msgstr "" 2007 2032 ··· 2143 2168 2144 2169 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:162 2145 2170 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:170 2146 - #: src/screens/Onboarding/StepInterests/index.tsx:244 2171 + #: src/screens/Onboarding/StepInterests/index.tsx:253 2147 2172 #: src/screens/Onboarding/StepProfile/index.tsx:277 2173 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:246 2148 2174 msgid "Continue" 2149 2175 msgstr "" 2150 2176 ··· 2160 2186 msgid "Continue thread..." 2161 2187 msgstr "" 2162 2188 2163 - #: src/screens/Onboarding/StepInterests/index.tsx:241 2189 + #: src/screens/Onboarding/StepInterests/index.tsx:250 2164 2190 #: src/screens/Onboarding/StepProfile/index.tsx:274 2191 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:243 2165 2192 #: src/screens/Signup/BackNextButtons.tsx:60 2166 2193 msgid "Continue to next step" 2167 2194 msgstr "" ··· 2180 2207 msgid "Conversation deleted" 2181 2208 msgstr "" 2182 2209 2183 - #: src/screens/Onboarding/index.tsx:41 2210 + #: src/screens/Onboarding/index.tsx:55 2184 2211 msgid "Cooking" 2185 2212 msgstr "" 2186 2213 ··· 2234 2261 msgid "Copy code" 2235 2262 msgstr "" 2236 2263 2237 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:491 2264 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:501 2238 2265 #: src/view/com/profile/ProfileMenu.tsx:442 2239 2266 #: src/view/com/profile/ProfileMenu.tsx:445 2240 2267 msgid "Copy DID" 2241 2268 msgstr "" 2242 2269 2243 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:424 2270 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:434 2244 2271 msgid "Copy host" 2245 2272 msgstr "" 2246 2273 ··· 2292 2319 msgid "Copy QR code" 2293 2320 msgstr "" 2294 2321 2295 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:445 2322 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:455 2296 2323 msgid "Copy TXT record value" 2297 2324 msgstr "" 2298 2325 ··· 2346 2373 2347 2374 #. Text on button to create a new starter pack 2348 2375 #. Text on button to create a new starter pack 2349 - #: src/components/dialogs/StarterPackDialog.tsx:114 2350 - #: src/components/dialogs/StarterPackDialog.tsx:216 2376 + #: src/components/dialogs/StarterPackDialog.tsx:113 2377 + #: src/components/dialogs/StarterPackDialog.tsx:212 2351 2378 #: src/components/StarterPack/ProfileStarterPacks.tsx:300 2352 2379 msgid "Create" 2353 2380 msgstr "" ··· 2412 2439 msgid "Create report for {0}" 2413 2440 msgstr "" 2414 2441 2415 - #: src/components/dialogs/StarterPackDialog.tsx:109 2416 - #: src/components/dialogs/StarterPackDialog.tsx:211 2442 + #: src/components/dialogs/StarterPackDialog.tsx:108 2443 + #: src/components/dialogs/StarterPackDialog.tsx:207 2417 2444 msgid "Create starter pack" 2418 2445 msgstr "" 2419 2446 ··· 2425 2452 msgid "Creator has been blocked" 2426 2453 msgstr "" 2427 2454 2428 - #: src/screens/Onboarding/index.tsx:26 2429 - #: src/screens/Onboarding/state.ts:102 2455 + #: src/screens/Onboarding/index.tsx:40 2456 + #: src/screens/Onboarding/state.ts:107 2430 2457 msgid "Culture" 2431 2458 msgstr "" 2432 2459 ··· 2443 2470 msgid "Customize who can interact with this post." 2444 2471 msgstr "" 2445 2472 2446 - #: src/screens/Onboarding/Layout.tsx:56 2473 + #: src/screens/Onboarding/Layout.tsx:61 2447 2474 msgid "Customizes your Bluesky experience" 2448 2475 msgstr "" 2449 2476 ··· 2459 2486 msgid "Dark" 2460 2487 msgstr "" 2461 2488 2462 - #: src/view/screens/Debug.tsx:69 2489 + #: src/view/screens/Debug.tsx:68 2463 2490 msgid "Dark mode" 2464 2491 msgstr "" 2465 2492 ··· 2481 2508 msgid "Debug Moderation" 2482 2509 msgstr "" 2483 2510 2484 - #: src/view/screens/Debug.tsx:89 2511 + #: src/view/screens/Debug.tsx:88 2485 2512 msgid "Debug panel" 2486 2513 msgstr "" 2487 2514 ··· 2600 2627 msgid "Deleted list" 2601 2628 msgstr "" 2602 2629 2603 - #: src/screens/Profile/Header/EditProfileDialog.tsx:360 2630 + #: src/screens/Profile/Header/EditProfileDialog.tsx:365 2604 2631 #: src/view/com/modals/CreateOrEditList.tsx:278 2605 2632 #: src/view/com/modals/CreateOrEditList.tsx:299 2606 2633 msgid "Description" ··· 2707 2734 msgid "Discover New Feeds" 2708 2735 msgstr "" 2709 2736 2710 - #: src/components/Dialog/index.tsx:321 2737 + #: src/components/Dialog/index.tsx:370 2711 2738 msgid "Dismiss" 2712 2739 msgstr "" 2713 2740 ··· 2734 2761 2735 2762 #: src/screens/Profile/Header/EditProfileDialog.tsx:315 2736 2763 #: src/screens/Profile/Header/EditProfileDialog.tsx:321 2737 - #: src/screens/Profile/Header/EditProfileDialog.tsx:367 2764 + #: src/screens/Profile/Header/EditProfileDialog.tsx:372 2738 2765 msgid "Display name" 2739 2766 msgstr "" 2740 2767 2741 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:392 2742 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:394 2768 + #: src/screens/Onboarding/StepFinished.tsx:347 2769 + msgid "Ditch the trolls and clickbait. Find real people and conversations that matter to you." 2770 + msgstr "" 2771 + 2772 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:402 2773 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:404 2743 2774 msgid "DNS Panel" 2744 2775 msgstr "" 2745 2776 ··· 2751 2782 msgid "Does not include nudity." 2752 2783 msgstr "" 2753 2784 2754 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:507 2785 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:517 2755 2786 msgid "Domain verified!" 2756 2787 msgstr "" 2757 2788 ··· 2772 2803 msgid "Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult." 2773 2804 msgstr "" 2774 2805 2775 - #: src/components/dialogs/BirthDateSettings.tsx:115 2776 - #: src/components/dialogs/BirthDateSettings.tsx:121 2806 + #: src/components/dialogs/BirthDateSettings.tsx:149 2807 + #: src/components/dialogs/BirthDateSettings.tsx:156 2777 2808 #: src/components/dms/ReportDialog.tsx:314 2778 2809 #: src/components/forms/DateField/index.tsx:103 2779 2810 #: src/components/forms/DateField/index.tsx:109 ··· 2787 2818 #: src/view/com/auth/server-input/index.tsx:233 2788 2819 #: src/view/com/composer/labels/LabelsBtn.tsx:223 2789 2820 #: src/view/com/composer/labels/LabelsBtn.tsx:230 2821 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:345 2790 2822 #: src/view/com/composer/videos/SubtitleDialog.tsx:168 2791 2823 #: src/view/com/composer/videos/SubtitleDialog.tsx:178 2792 2824 #: src/view/com/modals/CropImage.web.tsx:112 ··· 2809 2841 msgid "Double tap or long press the message to add a reaction" 2810 2842 msgstr "" 2811 2843 2812 - #: src/components/Dialog/index.tsx:322 2844 + #: src/components/Dialog/index.tsx:371 2813 2845 msgid "Double tap to close the dialog" 2814 2846 msgstr "" 2815 2847 ··· 2834 2866 msgid "Duration:" 2835 2867 msgstr "" 2836 2868 2837 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:230 2869 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:235 2838 2870 msgid "e.g. alice" 2839 2871 msgstr "" 2840 2872 ··· 2842 2874 msgid "e.g. Alice Lastname" 2843 2875 msgstr "" 2844 2876 2845 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:376 2877 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:386 2846 2878 msgid "e.g. alice.com" 2847 2879 msgstr "" 2848 2880 ··· 2968 3000 msgid "Edit your starter pack" 2969 3001 msgstr "" 2970 3002 2971 - #: src/screens/Onboarding/index.tsx:31 2972 - #: src/screens/Onboarding/state.ts:104 3003 + #: src/screens/Onboarding/index.tsx:45 3004 + #: src/screens/Onboarding/state.ts:109 2973 3005 msgid "Education" 2974 3006 msgstr "" 2975 3007 ··· 3117 3149 msgid "Enter fullscreen" 3118 3150 msgstr "" 3119 3151 3120 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:370 3152 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:380 3121 3153 msgid "Enter the domain you want to use" 3122 3154 msgstr "" 3123 3155 ··· 3129 3161 msgid "Enter the username or email address you used when you created your account" 3130 3162 msgstr "" 3131 3163 3132 - #: src/components/dialogs/BirthDateSettings.tsx:104 3164 + #: src/components/dialogs/BirthDateSettings.tsx:116 3133 3165 msgid "Enter your birth date" 3134 3166 msgstr "" 3135 3167 ··· 3179 3211 msgid "Error receiving captcha response." 3180 3212 msgstr "" 3181 3213 3182 - #: src/screens/Onboarding/StepInterests/index.tsx:183 3214 + #: src/screens/Onboarding/StepInterests/index.tsx:192 3183 3215 msgid "Error:" 3184 3216 msgstr "" 3185 3217 ··· 3345 3377 msgid "Failed to add emoji reaction" 3346 3378 msgstr "" 3347 3379 3348 - #: src/components/dialogs/StarterPackDialog.tsx:287 3380 + #: src/components/dialogs/StarterPackDialog.tsx:283 3349 3381 msgid "Failed to add to starter pack" 3350 3382 msgstr "" 3351 3383 3352 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:587 3384 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:597 3353 3385 msgid "Failed to change handle. Please try again." 3354 3386 msgstr "" 3355 3387 ··· 3388 3420 msgid "Failed to delete starter pack" 3389 3421 msgstr "" 3390 3422 3423 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:126 3424 + msgid "Failed to follow all suggested accounts, please try again" 3425 + msgstr "" 3426 + 3391 3427 #: src/screens/Messages/ChatList.tsx:270 3392 3428 #: src/screens/Messages/Inbox.tsx:208 3393 3429 msgid "Failed to load conversations" ··· 3451 3487 msgid "Failed to remove emoji reaction" 3452 3488 msgstr "" 3453 3489 3454 - #: src/components/dialogs/StarterPackDialog.tsx:306 3490 + #: src/components/dialogs/StarterPackDialog.tsx:302 3455 3491 msgid "Failed to remove from starter pack" 3456 3492 msgstr "" 3457 3493 ··· 3522 3558 msgid "Failed to verify email, please try again." 3523 3559 msgstr "" 3524 3560 3525 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:360 3561 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:370 3526 3562 msgid "Failed to verify handle. Please try again." 3527 3563 msgstr "" 3528 3564 ··· 3620 3656 msgid "Filter who you receive notifications from" 3621 3657 msgstr "" 3622 3658 3623 - #: src/screens/Onboarding/StepFinished.tsx:314 3659 + #: src/screens/Onboarding/StepFinished.tsx:479 3660 + #: src/screens/Onboarding/StepFinished.tsx:590 3624 3661 msgid "Finalizing" 3625 3662 msgstr "" 3626 3663 ··· 3630 3667 msgid "Find accounts to follow" 3631 3668 msgstr "" 3632 3669 3633 - #: src/components/ProgressGuide/FollowDialog.tsx:77 3634 - #: src/components/ProgressGuide/FollowDialog.tsx:87 3635 - #: src/components/ProgressGuide/FollowDialog.tsx:374 3670 + #: src/components/ProgressGuide/FollowDialog.tsx:69 3671 + #: src/components/ProgressGuide/FollowDialog.tsx:79 3672 + #: src/components/ProgressGuide/FollowDialog.tsx:367 3636 3673 msgid "Find people to follow" 3637 3674 msgstr "" 3638 3675 ··· 3640 3677 msgid "Find posts, users, and feeds on Bluesky" 3641 3678 msgstr "" 3642 3679 3680 + #: src/screens/Onboarding/StepFinished.tsx:345 3681 + msgid "Find your people" 3682 + msgstr "" 3683 + 3643 3684 #: src/screens/StarterPack/Wizard/index.tsx:218 3644 3685 msgid "Finish" 3645 3686 msgstr "" 3646 3687 3647 - #: src/screens/Onboarding/index.tsx:35 3688 + #: src/screens/Onboarding/index.tsx:49 3648 3689 msgid "Fitness" 3649 3690 msgstr "" 3650 3691 ··· 3663 3704 msgid "Flat White" 3664 3705 msgstr "" 3665 3706 3666 - #: src/screens/Onboarding/StepFinished.tsx:294 3707 + #: src/screens/Onboarding/StepFinished.tsx:571 3667 3708 msgid "Flexible" 3668 3709 msgstr "" 3669 3710 ··· 3705 3746 msgid "Follow account" 3706 3747 msgstr "" 3707 3748 3749 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:235 3708 3750 #: src/screens/StarterPack/StarterPackScreen.tsx:438 3709 3751 #: src/screens/StarterPack/StarterPackScreen.tsx:446 3710 3752 msgid "Follow all" 3711 3753 msgstr "" 3712 3754 3755 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:232 3756 + msgid "Follow all accounts" 3757 + msgstr "" 3758 + 3713 3759 #. User is not following this account, click to follow back 3714 3760 #: src/components/ProfileCard.tsx:518 3715 3761 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:129 ··· 3720 3766 #: src/view/com/profile/FollowButton.tsx:81 3721 3767 msgctxt "action" 3722 3768 msgid "Follow back" 3769 + msgstr "" 3770 + 3771 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:121 3772 + msgid "Followed all accounts!" 3723 3773 msgstr "" 3724 3774 3725 3775 #: src/components/KnownFollowers.tsx:238 ··· 3798 3848 msgid "Font size" 3799 3849 msgstr "" 3800 3850 3801 - #: src/screens/Onboarding/index.tsx:40 3802 - #: src/screens/Onboarding/state.ts:105 3851 + #: src/screens/Onboarding/index.tsx:54 3852 + #: src/screens/Onboarding/state.ts:110 3803 3853 msgid "Food" 3804 3854 msgstr "" 3805 3855 ··· 3819 3869 msgid "For the best experience, we recommend using the theme font." 3820 3870 msgstr "" 3821 3871 3872 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:293 3822 3873 #: src/screens/Search/modules/ExploreSuggestedAccounts.tsx:94 3823 3874 msgid "For You" 3824 3875 msgstr "" ··· 3827 3878 msgid "Forever" 3828 3879 msgstr "" 3829 3880 3881 + #: src/screens/Onboarding/StepFinished.tsx:354 3882 + msgid "Forget the noise" 3883 + msgstr "" 3884 + 3830 3885 #: src/screens/Login/index.tsx:153 3831 3886 #: src/screens/Login/index.tsx:168 3832 3887 msgid "Forgot Password" ··· 3840 3895 msgid "Forgot?" 3841 3896 msgstr "" 3842 3897 3898 + #: src/screens/Onboarding/StepFinished.tsx:336 3899 + msgid "Free your feed" 3900 + msgstr "" 3901 + 3843 3902 #: src/components/moderation/ReportDialog/utils/useReportOptions.ts:54 3844 3903 #: src/lib/moderation/useReportOptions.ts:54 3845 3904 msgid "Frequently Posts Unwanted Content" ··· 3974 4033 #: src/components/dms/ReportDialog.tsx:192 3975 4034 #: src/components/ReportDialog/SelectReportOptionView.tsx:78 3976 4035 #: src/components/ReportDialog/SubmitView.tsx:110 3977 - #: src/screens/Onboarding/Layout.tsx:99 3978 - #: src/screens/Onboarding/Layout.tsx:188 4036 + #: src/screens/Onboarding/Layout.tsx:121 4037 + #: src/screens/Onboarding/Layout.tsx:214 3979 4038 #: src/screens/Signup/BackNextButtons.tsx:35 3980 4039 msgid "Go back to previous step" 3981 4040 msgstr "" ··· 4050 4109 msgid "Handle" 4051 4110 msgstr "" 4052 4111 4053 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:591 4112 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:601 4054 4113 msgid "Handle already taken. Please try a different one." 4055 4114 msgstr "" 4056 4115 4057 4116 #: src/screens/Settings/components/ChangeHandleDialog.tsx:195 4058 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:344 4117 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:354 4059 4118 msgid "Handle changed!" 4060 4119 msgstr "" 4061 4120 4062 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:595 4121 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:605 4063 4122 msgid "Handle too long. Please try a shorter one." 4064 4123 msgstr "" 4065 4124 ··· 4192 4251 msgid "Hide user list" 4193 4252 msgstr "" 4194 4253 4195 - #: src/screens/Moderation/VerificationSettings.tsx:85 4196 - #: src/screens/Moderation/VerificationSettings.tsx:94 4254 + #: src/screens/Moderation/VerificationSettings.tsx:90 4255 + #: src/screens/Moderation/VerificationSettings.tsx:99 4197 4256 msgid "Hide verification badges" 4198 4257 msgstr "" 4199 4258 ··· 4242 4301 msgid "Home" 4243 4302 msgstr "" 4244 4303 4245 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:417 4304 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:427 4246 4305 msgid "Host:" 4247 4306 msgstr "" 4248 4307 ··· 4265 4324 msgid "I have a code" 4266 4325 msgstr "" 4267 4326 4268 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:280 4269 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:286 4327 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:290 4328 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:296 4270 4329 msgid "I have my own domain" 4271 4330 msgstr "" 4272 4331 ··· 4287 4346 msgid "If you delete this list, you won't be able to recover it." 4288 4347 msgstr "" 4289 4348 4290 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:267 4349 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:272 4291 4350 msgid "If you have your own domain, you can use that as your handle. This lets you self-verify your identity. <0>Learn more here.</0>" 4292 4351 msgstr "" 4293 4352 ··· 4434 4493 msgid "Invalid 2FA confirmation code." 4435 4494 msgstr "" 4436 4495 4437 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:597 4496 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:607 4438 4497 msgid "Invalid handle. Please try a different one." 4439 4498 msgstr "" 4440 4499 ··· 4512 4571 msgid "Join the conversation" 4513 4572 msgstr "" 4514 4573 4515 - #: src/screens/Onboarding/index.tsx:21 4516 - #: src/screens/Onboarding/state.ts:107 4574 + #: src/screens/Onboarding/index.tsx:35 4575 + #: src/screens/Onboarding/state.ts:112 4517 4576 msgid "Journalism" 4518 4577 msgstr "" 4519 4578 ··· 4560 4619 4561 4620 #: src/components/moderation/LabelsOnMeDialog.tsx:74 4562 4621 msgid "Labels on your content" 4563 - msgstr "" 4564 - 4565 - #: src/view/com/composer/select-language/SelectLangBtn.tsx:107 4566 - msgid "Language selection" 4567 4622 msgstr "" 4568 4623 4569 4624 #: src/Navigation.tsx:217 ··· 4594 4649 msgid "Latest" 4595 4650 msgstr "" 4596 4651 4597 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:271 4598 - msgid "learn more" 4599 - msgstr "" 4600 - 4601 - #: src/components/verification/VerificationsDialog.tsx:165 4602 - #: src/components/verification/VerifierDialog.tsx:132 4603 - #: src/screens/Moderation/VerificationSettings.tsx:47 4604 - #: src/screens/Profile/Header/EditProfileDialog.tsx:350 4605 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:212 4652 + #: src/components/verification/VerificationsDialog.tsx:170 4653 + #: src/components/verification/VerifierDialog.tsx:137 4654 + #: src/screens/Moderation/VerificationSettings.tsx:48 4655 + #: src/screens/Profile/Header/EditProfileDialog.tsx:351 4656 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:213 4657 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:277 4658 + msgctxt "english-only-resource" 4606 4659 msgid "Learn more" 4607 4660 msgstr "" 4608 4661 ··· 4623 4676 msgstr "" 4624 4677 4625 4678 #: src/components/moderation/ContentHider.tsx:152 4626 - msgid "Learn more about the moderation applied to this content" 4627 - msgstr "" 4628 - 4629 4679 #: src/components/moderation/ContentHider.tsx:218 4630 - msgid "Learn more about the moderation applied to this content." 4680 + msgid "Learn more about the moderation applied to this content" 4631 4681 msgstr "" 4632 4682 4633 4683 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:149 ··· 4643 4693 msgid "Learn more about this warning" 4644 4694 msgstr "" 4645 4695 4646 - #: src/components/verification/VerificationsDialog.tsx:150 4647 - #: src/components/verification/VerifierDialog.tsx:117 4696 + #: src/components/verification/VerificationsDialog.tsx:151 4697 + #: src/components/verification/VerifierDialog.tsx:118 4698 + msgctxt "english-only-resource" 4648 4699 msgid "Learn more about verification on Bluesky" 4649 4700 msgstr "" 4650 4701 ··· 4701 4752 msgid "Let's get your password reset!" 4702 4753 msgstr "" 4703 4754 4704 - #: src/screens/Onboarding/StepFinished.tsx:314 4755 + #: src/screens/Onboarding/StepFinished.tsx:481 4756 + #: src/screens/Onboarding/StepFinished.tsx:590 4705 4757 msgid "Let's go!" 4706 4758 msgstr "" 4707 4759 ··· 4901 4953 4902 4954 #: src/screens/Profile/ProfileFeed/index.tsx:224 4903 4955 #: src/screens/Profile/Sections/Feed.tsx:98 4904 - #: src/view/com/feeds/FeedPage.tsx:155 4956 + #: src/view/com/feeds/FeedPage.tsx:162 4905 4957 #: src/view/screens/ProfileList.tsx:878 4906 4958 msgid "Load new posts" 4907 4959 msgstr "" ··· 5153 5205 msgid "More options" 5154 5206 msgstr "" 5155 5207 5156 - #: src/screens/Onboarding/state.ts:108 5208 + #: src/screens/Onboarding/state.ts:113 5157 5209 msgid "Movies" 5158 5210 msgstr "" 5159 5211 5160 - #: src/screens/Onboarding/state.ts:109 5212 + #: src/screens/Onboarding/state.ts:114 5161 5213 msgid "Music" 5162 5214 msgstr "" 5163 5215 ··· 5259 5311 msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them." 5260 5312 msgstr "" 5261 5313 5262 - #: src/components/dialogs/BirthDateSettings.tsx:35 5263 5314 #: src/components/dialogs/BirthDateSettings.tsx:38 5315 + #: src/components/dialogs/BirthDateSettings.tsx:42 5264 5316 msgid "My Birthday" 5265 5317 msgstr "" 5266 5318 ··· 5287 5339 msgid "Name or Description Violates Community Standards" 5288 5340 msgstr "" 5289 5341 5290 - #: src/screens/Onboarding/index.tsx:22 5291 - #: src/screens/Onboarding/state.ts:110 5342 + #: src/screens/Onboarding/index.tsx:36 5343 + #: src/screens/Onboarding/state.ts:115 5292 5344 msgid "Nature" 5293 5345 msgstr "" 5294 5346 ··· 5319 5371 msgid "Need to report a copyright violation?" 5320 5372 msgstr "" 5321 5373 5322 - #: src/screens/Onboarding/StepFinished.tsx:282 5374 + #: src/screens/Onboarding/StepFinished.tsx:559 5323 5375 msgid "Never lose access to your followers or data." 5324 5376 msgstr "" 5325 5377 5326 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:567 5378 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:577 5327 5379 msgid "Nevermind, create a handle for me" 5328 5380 msgstr "" 5329 5381 ··· 5370 5422 msgid "New followers" 5371 5423 msgstr "" 5372 5424 5373 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:221 5374 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:229 5375 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:375 5425 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:226 5426 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:234 5427 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:385 5376 5428 msgid "New handle" 5377 5429 msgstr "" 5378 5430 ··· 5404 5456 msgid "New post" 5405 5457 msgstr "" 5406 5458 5407 - #: src/view/com/feeds/FeedPage.tsx:166 5459 + #: src/view/com/feeds/FeedPage.tsx:173 5408 5460 msgctxt "action" 5409 5461 msgid "New post" 5410 5462 msgstr "" ··· 5422 5474 msgid "New posts from {firstAuthorName} and {additionalAuthorsCount, plural, one {{formattedAuthorsCount} other} other {{formattedAuthorsCount} others}}" 5423 5475 msgstr "" 5424 5476 5425 - #: src/components/dialogs/StarterPackDialog.tsx:208 5477 + #: src/components/dialogs/StarterPackDialog.tsx:204 5426 5478 msgid "New starter pack" 5427 5479 msgstr "" 5428 5480 ··· 5441 5493 msgid "Newest replies first" 5442 5494 msgstr "" 5443 5495 5444 - #: src/screens/Onboarding/index.tsx:20 5445 - #: src/screens/Onboarding/state.ts:111 5496 + #: src/screens/Onboarding/index.tsx:34 5497 + #: src/screens/Onboarding/state.ts:116 5446 5498 #: src/screens/Search/modules/ExploreTrendingTopics.tsx:238 5447 5499 msgid "News" 5448 5500 msgstr "" ··· 5453 5505 #: src/screens/Login/LoginForm.tsx:350 5454 5506 #: src/screens/Login/SetNewPasswordForm.tsx:182 5455 5507 #: src/screens/Login/SetNewPasswordForm.tsx:188 5508 + #: src/screens/Onboarding/StepFinished.tsx:474 5509 + #: src/screens/Onboarding/StepFinished.tsx:483 5456 5510 #: src/screens/Settings/components/AddAppPasswordDialog.tsx:157 5457 5511 #: src/screens/Settings/components/AddAppPasswordDialog.tsx:165 5458 5512 #: src/screens/Signup/BackNextButtons.tsx:67 ··· 5472 5526 msgid "Next image" 5473 5527 msgstr "" 5474 5528 5529 + #: src/screens/Onboarding/StepFinished.tsx:356 5530 + msgid "No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention." 5531 + msgstr "" 5532 + 5475 5533 #: src/screens/Settings/AppPasswords.tsx:108 5476 5534 msgid "No app passwords yet" 5477 5535 msgstr "" 5478 5536 5479 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:397 5480 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:399 5537 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:407 5538 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:409 5481 5539 msgid "No DNS Panel" 5482 5540 msgstr "" 5483 5541 ··· 5511 5569 msgid "No messages yet" 5512 5570 msgstr "" 5513 5571 5572 + #: src/screens/Onboarding/StepFinished.tsx:338 5573 + msgid "No more doomscrolling junk-filled algorithms. Find feeds that work for you, not against you." 5574 + msgstr "" 5575 + 5514 5576 #: src/view/com/notifications/NotificationFeed.tsx:122 5515 5577 msgid "No notifications yet!" 5516 5578 msgstr "" ··· 5549 5611 msgstr "" 5550 5612 5551 5613 #: src/components/dialogs/SearchablePeopleList.tsx:223 5552 - #: src/components/ProgressGuide/FollowDialog.tsx:212 5614 + #: src/components/ProgressGuide/FollowDialog.tsx:204 5553 5615 msgid "No results" 5554 5616 msgstr "" 5555 5617 ··· 5703 5765 msgstr "" 5704 5766 5705 5767 #: src/components/dialogs/GifSelect.tsx:267 5768 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:365 5706 5769 #: src/view/com/util/ErrorBoundary.tsx:57 5707 5770 msgid "Oh no!" 5708 5771 msgstr "" ··· 5794 5857 msgid "Oops!" 5795 5858 msgstr "" 5796 5859 5797 - #: src/screens/Onboarding/StepFinished.tsx:278 5860 + #: src/screens/Onboarding/StepFinished.tsx:555 5798 5861 msgid "Open" 5799 5862 msgstr "" 5800 5863 ··· 5875 5938 msgid "Open system log" 5876 5939 msgstr "" 5877 5940 5878 - #: src/view/com/util/forms/DropdownButton.tsx:167 5879 - msgid "Opens {numItems} options" 5880 - msgstr "" 5881 - 5882 5941 #: src/view/com/composer/labels/LabelsBtn.tsx:62 5883 5942 msgid "Opens a dialog to add a content warning to your post" 5884 5943 msgstr "" ··· 5952 6011 5953 6012 #: src/screens/Login/LoginForm.tsx:249 5954 6013 msgid "Opens password reset form" 6014 + msgstr "" 6015 + 6016 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:58 6017 + msgid "Opens post language settings" 5955 6018 msgstr "" 5956 6019 5957 6020 #: src/view/com/notifications/NotificationFeedItem.tsx:906 ··· 6006 6069 msgid "Other account" 6007 6070 msgstr "" 6008 6071 6009 - #: src/view/com/composer/select-language/SelectLangBtn.tsx:93 6010 - msgid "Other..." 6011 - msgstr "" 6012 - 6013 6072 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:50 6014 6073 msgid "Our blog post" 6015 6074 msgstr "" ··· 6093 6152 msgid "Person toggle" 6094 6153 msgstr "" 6095 6154 6096 - #: src/screens/Onboarding/index.tsx:28 6097 - #: src/screens/Onboarding/state.ts:112 6155 + #: src/screens/Onboarding/index.tsx:42 6156 + #: src/screens/Onboarding/state.ts:117 6098 6157 msgid "Pets" 6099 6158 msgstr "" 6100 6159 6101 - #: src/screens/Onboarding/state.ts:113 6160 + #: src/screens/Onboarding/state.ts:118 6102 6161 msgid "Photography" 6103 6162 msgstr "" 6104 6163 ··· 6320 6379 msgid "Please write your message below:" 6321 6380 msgstr "" 6322 6381 6323 - #: src/screens/Onboarding/index.tsx:34 6324 - #: src/screens/Onboarding/state.ts:114 6382 + #: src/screens/Onboarding/index.tsx:48 6383 + #: src/screens/Onboarding/state.ts:119 6325 6384 #: src/screens/Search/modules/ExploreTrendingTopics.tsx:232 6326 6385 msgid "Politics" 6327 6386 msgstr "" ··· 6391 6450 msgid "Post Interaction Settings" 6392 6451 msgstr "" 6393 6452 6394 - #: src/view/com/composer/select-language/SelectLangBtn.tsx:89 6395 - msgid "Post language" 6396 - msgstr "" 6397 - 6398 - #: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:77 6399 - msgid "Post Languages" 6453 + #. Accessibility label for button that opens dialog to choose post language settings 6454 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:53 6455 + msgid "Post language selection" 6400 6456 msgstr "" 6401 6457 6402 6458 #: src/screens/PostThread/components/ThreadError.tsx:32 ··· 6529 6585 msgid "Profile updated" 6530 6586 msgstr "" 6531 6587 6532 - #: src/screens/Onboarding/StepFinished.tsx:264 6588 + #: src/screens/Onboarding/StepFinished.tsx:541 6533 6589 msgid "Public" 6534 6590 msgstr "" 6535 6591 ··· 6630 6686 msgid "Quotes of this post" 6631 6687 msgstr "" 6632 6688 6633 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:600 6689 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:610 6634 6690 msgid "Rate limit exceeded – you've tried to change your handle too many times in a short period. Please wait a minute before trying again." 6635 6691 msgstr "" 6636 6692 ··· 6708 6764 msgid "Recent Searches" 6709 6765 msgstr "" 6710 6766 6767 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:274 6768 + msgid "Recently used" 6769 + msgstr "" 6770 + 6711 6771 #: src/screens/Search/modules/ExploreRecommendations.tsx:54 6712 6772 msgid "Recommended" 6713 6773 msgstr "" ··· 6732 6792 6733 6793 #: src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx:171 6734 6794 #: src/components/dialogs/MutedWords.tsx:438 6735 - #: src/components/dialogs/StarterPackDialog.tsx:388 6736 - #: src/components/dialogs/StarterPackDialog.tsx:394 6795 + #: src/components/dialogs/StarterPackDialog.tsx:384 6796 + #: src/components/dialogs/StarterPackDialog.tsx:390 6737 6797 #: src/components/FeedCard.tsx:343 6738 6798 #: src/components/StarterPack/Wizard/WizardListCard.tsx:104 6739 6799 #: src/components/StarterPack/Wizard/WizardListCard.tsx:111 ··· 6839 6899 msgstr "" 6840 6900 6841 6901 #: src/components/verification/VerificationRemovePrompt.tsx:46 6842 - #: src/components/verification/VerificationsDialog.tsx:247 6902 + #: src/components/verification/VerificationsDialog.tsx:252 6843 6903 #: src/view/com/profile/ProfileMenu.tsx:348 6844 6904 #: src/view/com/profile/ProfileMenu.tsx:351 6845 6905 msgid "Remove verification" ··· 6866 6926 msgid "Removed from saved feeds" 6867 6927 msgstr "" 6868 6928 6869 - #: src/components/dialogs/StarterPackDialog.tsx:294 6929 + #: src/components/dialogs/StarterPackDialog.tsx:290 6870 6930 msgid "Removed from starter pack" 6871 6931 msgstr "" 6872 6932 ··· 7195 7255 #: src/screens/Messages/ChatList.tsx:280 7196 7256 #: src/screens/Messages/components/MessageListError.tsx:25 7197 7257 #: src/screens/Messages/Inbox.tsx:218 7198 - #: src/screens/Onboarding/StepInterests/index.tsx:217 7199 - #: src/screens/Onboarding/StepInterests/index.tsx:220 7258 + #: src/screens/Onboarding/StepInterests/index.tsx:226 7259 + #: src/screens/Onboarding/StepInterests/index.tsx:229 7260 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:209 7261 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:212 7200 7262 #: src/screens/PostThread/components/ThreadError.tsx:75 7201 7263 #: src/screens/PostThread/components/ThreadError.tsx:81 7202 7264 #: src/screens/Signup/BackNextButtons.tsx:53 ··· 7220 7282 msgstr "" 7221 7283 7222 7284 #: src/screens/Profile/ProfileFeed/index.tsx:93 7223 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:559 7285 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:569 7224 7286 #: src/screens/VideoFeed/index.tsx:1146 7225 7287 #: src/view/screens/NotFound.tsx:60 7226 7288 #: src/view/screens/ProfileList.tsx:1039 ··· 7231 7293 msgid "Returns to the previous step" 7232 7294 msgstr "" 7233 7295 7234 - #: src/components/dialogs/BirthDateSettings.tsx:121 7296 + #: src/components/dialogs/BirthDateSettings.tsx:156 7235 7297 #: src/components/dialogs/PostInteractionSettingsDialog.tsx:483 7236 7298 #: src/components/dialogs/PostInteractionSettingsDialog.tsx:489 7237 7299 #: src/components/live/EditLiveDialog.tsx:216 ··· 7239 7301 #: src/components/StarterPack/QrCodeDialog.tsx:192 7240 7302 #: src/screens/Profile/Header/EditProfileDialog.tsx:238 7241 7303 #: src/screens/Profile/Header/EditProfileDialog.tsx:252 7242 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:262 7304 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:267 7243 7305 #: src/view/com/composer/GifAltText.tsx:193 7244 7306 #: src/view/com/composer/GifAltText.tsx:202 7245 7307 #: src/view/com/composer/photos/EditImageDialog.web.tsx:62 ··· 7257 7319 msgid "Save" 7258 7320 msgstr "" 7259 7321 7260 - #: src/components/dialogs/BirthDateSettings.tsx:115 7322 + #: src/components/dialogs/BirthDateSettings.tsx:149 7261 7323 msgid "Save birthday" 7262 7324 msgstr "" 7263 7325 ··· 7277 7339 msgid "Save image crop" 7278 7340 msgstr "" 7279 7341 7280 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:248 7342 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:253 7281 7343 msgid "Save new handle" 7282 7344 msgstr "" 7283 7345 ··· 7310 7372 msgid "Say hello!" 7311 7373 msgstr "" 7312 7374 7313 - #: src/screens/Onboarding/index.tsx:33 7314 - #: src/screens/Onboarding/state.ts:115 7375 + #: src/screens/Onboarding/index.tsx:47 7376 + #: src/screens/Onboarding/state.ts:120 7315 7377 msgid "Science" 7316 7378 msgstr "" 7317 7379 7380 + #: src/components/InterestTabs.tsx:250 7381 + msgid "Scroll left" 7382 + msgstr "" 7383 + 7384 + #: src/components/InterestTabs.tsx:284 7385 + msgid "Scroll right" 7386 + msgstr "" 7387 + 7318 7388 #: src/view/screens/ProfileList.tsx:996 7319 7389 msgid "Scroll to top" 7320 7390 msgstr "" ··· 7333 7403 msgid "Search @{0}'s posts" 7334 7404 msgstr "" 7335 7405 7336 - #: src/components/ProgressGuide/FollowDialog.tsx:683 7406 + #: src/components/ProgressGuide/FollowDialog.tsx:595 7337 7407 msgid "Search by name or interest" 7338 7408 msgstr "" 7339 7409 ··· 7341 7411 msgid "Search feeds" 7342 7412 msgstr "" 7343 7413 7344 - #: src/components/ProgressGuide/FollowDialog.tsx:524 7345 - #: src/screens/Search/modules/ExploreSuggestedAccounts.tsx:135 7346 - msgid "Search for \"{interestsDisplayName}\"{activeText}" 7414 + #. Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected. 7415 + #: src/components/ProgressGuide/FollowDialog.tsx:425 7416 + msgid "Search for \"{interestsDisplayName}\"" 7417 + msgstr "" 7418 + 7419 + #. Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected. 7420 + #: src/components/ProgressGuide/FollowDialog.tsx:418 7421 + msgid "Search for \"{interestsDisplayName}\" (active)" 7347 7422 msgstr "" 7348 7423 7349 7424 #: src/view/shell/desktop/Search.tsx:131 ··· 7376 7451 7377 7452 #: src/screens/Search/SearchResults.tsx:253 7378 7453 msgid "Search is currently unavailable when logged out" 7454 + msgstr "" 7455 + 7456 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:257 7457 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:258 7458 + msgid "Search languages" 7379 7459 msgstr "" 7380 7460 7381 7461 #: src/screens/Profile/ProfileSearch.tsx:36 ··· 7388 7468 msgstr "" 7389 7469 7390 7470 #: src/components/dialogs/SearchablePeopleList.tsx:534 7391 - #: src/components/ProgressGuide/FollowDialog.tsx:702 7471 + #: src/components/ProgressGuide/FollowDialog.tsx:614 7392 7472 msgid "Search profiles" 7393 7473 msgstr "" 7394 7474 ··· 7401 7481 msgstr "" 7402 7482 7403 7483 #: src/components/dialogs/SearchablePeopleList.tsx:535 7404 - #: src/components/ProgressGuide/FollowDialog.tsx:703 7484 + #: src/components/ProgressGuide/FollowDialog.tsx:615 7405 7485 msgid "Searches for profiles" 7406 7486 msgstr "" 7407 7487 ··· 7446 7526 msgid "Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause" 7447 7527 msgstr "" 7448 7528 7529 + #. Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is not currently active and can be selected. 7530 + #: src/components/InterestTabs.tsx:332 7531 + msgid "Select \"{interestsDisplayName}\" category" 7532 + msgstr "" 7533 + 7449 7534 #. Accessibility label for a username suggestion in the account creation flow 7450 7535 #: src/screens/Signup/StepHandle/HandleSuggestions.tsx:42 7451 7536 msgid "Select {0}" ··· 7513 7598 msgstr "" 7514 7599 7515 7600 #: src/screens/Settings/LanguageSettings.tsx:178 7601 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:290 7516 7602 msgid "Select languages" 7517 7603 msgstr "" 7518 7604 ··· 7541 7627 msgid "Select the moderation service(s) to report to" 7542 7628 msgstr "" 7543 7629 7630 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:236 7631 + msgid "Select up to 3 languages used in this post" 7632 + msgstr "" 7633 + 7544 7634 #: src/components/dialogs/MutedWords.tsx:242 7545 7635 msgid "Select what content this mute word should apply to." 7546 7636 msgstr "" ··· 7557 7647 msgid "Select your date of birth" 7558 7648 msgstr "" 7559 7649 7560 - #: src/screens/Onboarding/StepInterests/index.tsx:192 7650 + #: src/screens/Onboarding/StepInterests/index.tsx:201 7561 7651 #: src/screens/Settings/InterestsSettings.tsx:161 7562 7652 msgid "Select your interests from the options below" 7563 7653 msgstr "" ··· 7572 7662 7573 7663 #: src/view/com/composer/SelectMediaButton.tsx:393 7574 7664 msgid "Selecting multiple media types is not supported." 7575 - msgstr "" 7576 - 7577 - #: src/view/com/util/forms/DropdownButton.tsx:302 7578 - msgid "Selects option {0} of {numItems}" 7579 7665 msgstr "" 7580 7666 7581 7667 #: src/components/dms/ChatEmptyPill.tsx:38 ··· 7659 7745 msgid "Set new password" 7660 7746 msgstr "" 7661 7747 7662 - #: src/screens/Onboarding/Layout.tsx:47 7748 + #: src/screens/Onboarding/Layout.tsx:50 7663 7749 msgid "Set up your account" 7664 7750 msgstr "" 7665 7751 ··· 8006 8092 msgid "Similar accounts" 8007 8093 msgstr "" 8008 8094 8009 - #: src/screens/Onboarding/StepInterests/index.tsx:231 8095 + #: src/screens/Onboarding/StepFinished.tsx:380 8096 + #: src/screens/Onboarding/StepFinished.tsx:462 8097 + #: src/screens/Onboarding/StepInterests/index.tsx:240 8098 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:222 8010 8099 #: src/screens/StarterPack/Wizard/index.tsx:218 8011 8100 msgid "Skip" 8012 8101 msgstr "" 8013 8102 8014 - #: src/screens/Onboarding/StepInterests/index.tsx:228 8103 + #: src/screens/Onboarding/StepFinished.tsx:373 8104 + #: src/screens/Onboarding/StepFinished.tsx:459 8105 + msgid "Skip introduction and start using your account" 8106 + msgstr "" 8107 + 8108 + #: src/screens/Onboarding/StepInterests/index.tsx:237 8109 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:219 8015 8110 msgid "Skip this flow" 8016 8111 msgstr "" 8017 8112 ··· 8023 8118 msgid "Snoozes the reminder" 8024 8119 msgstr "" 8025 8120 8026 - #: src/screens/Onboarding/index.tsx:37 8027 - #: src/screens/Onboarding/state.ts:103 8121 + #: src/screens/Onboarding/index.tsx:51 8122 + #: src/screens/Onboarding/state.ts:108 8028 8123 msgid "Software Dev" 8029 8124 msgstr "" 8030 8125 ··· 8119 8214 msgid "Spam; excessive mentions or replies" 8120 8215 msgstr "" 8121 8216 8122 - #: src/screens/Onboarding/index.tsx:27 8123 - #: src/screens/Onboarding/state.ts:116 8217 + #: src/screens/Onboarding/index.tsx:41 8218 + #: src/screens/Onboarding/state.ts:121 8124 8219 #: src/screens/Search/modules/ExploreTrendingTopics.tsx:230 8125 8220 msgid "Sports" 8126 8221 msgstr "" ··· 8261 8356 msgid "Suggested Accounts" 8262 8357 msgstr "" 8263 8358 8359 + #. Accounts suggested to the user for them to follow 8264 8360 #: src/components/FeedInterstitials.tsx:385 8361 + #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:137 8265 8362 msgid "Suggested for you" 8266 8363 msgstr "" 8267 8364 ··· 8358 8455 msgid "Teach our algorithm what you like" 8359 8456 msgstr "" 8360 8457 8361 - #: src/screens/Onboarding/index.tsx:36 8362 - #: src/screens/Onboarding/state.ts:117 8458 + #: src/screens/Onboarding/index.tsx:50 8459 + #: src/screens/Onboarding/state.ts:122 8363 8460 msgid "Tech" 8364 8461 msgstr "" 8365 8462 ··· 8367 8464 msgid "Tell a joke!" 8368 8465 msgstr "" 8369 8466 8370 - #: src/screens/Profile/Header/EditProfileDialog.tsx:368 8467 + #: src/screens/Profile/Header/EditProfileDialog.tsx:373 8371 8468 msgid "Tell us a bit about yourself" 8372 8469 msgstr "" 8373 8470 ··· 8380 8477 msgid "Terms" 8381 8478 msgstr "" 8382 8479 8480 + #: src/components/dialogs/BirthDateSettings.tsx:135 8383 8481 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:30 8384 8482 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:97 8385 8483 #: src/Navigation.tsx:335 ··· 8427 8525 msgid "Thanks, you have successfully verified your email address. You can close this dialog." 8428 8526 msgstr "" 8429 8527 8430 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:487 8528 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:497 8431 8529 msgid "That contains the following:" 8432 8530 msgstr "" 8433 8531 ··· 8465 8563 #: src/components/moderation/ModerationDetailsDialog.tsx:119 8466 8564 #: src/lib/moderation/useModerationCauseDescription.ts:128 8467 8565 msgid "The author of this thread has hidden this reply." 8566 + msgstr "" 8567 + 8568 + #: src/components/dialogs/BirthDateSettings.tsx:122 8569 + msgid "The birthdate you've entered means you are under 18 years old. Certain content and features may be unavailable to you." 8468 8570 msgstr "" 8469 8571 8470 8572 #: src/screens/Moderation/index.tsx:426 ··· 8647 8749 msgstr "" 8648 8750 8649 8751 #: src/components/dialogs/GifSelect.tsx:269 8752 + #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:367 8650 8753 #: src/view/com/util/ErrorBoundary.tsx:59 8651 8754 msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" 8652 8755 msgstr "" ··· 8762 8865 msgid "This feed is no longer online. We are showing <0>Discover</0> instead." 8763 8866 msgstr "" 8764 8867 8765 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:593 8868 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:603 8766 8869 msgid "This handle is reserved. Please try a different one." 8767 8870 msgstr "" 8768 8871 8769 - #: src/components/dialogs/BirthDateSettings.tsx:41 8770 - msgid "This information is not shared with other users." 8872 + #: src/components/dialogs/BirthDateSettings.tsx:45 8873 + msgid "This information is private and not shared with other users." 8771 8874 msgstr "" 8772 8875 8773 8876 #: src/components/live/EditLiveDialog.tsx:189 ··· 8839 8942 msgid "This service has not provided terms of service or a privacy policy." 8840 8943 msgstr "" 8841 8944 8842 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:456 8945 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:466 8843 8946 msgid "This should create a domain record at:" 8844 8947 msgstr "" 8845 8948 ··· 8944 9047 msgid "Today" 8945 9048 msgstr "" 8946 9049 8947 - #: src/view/com/util/forms/DropdownButton.tsx:263 8948 - msgid "Toggle dropdown" 8949 - msgstr "" 8950 - 8951 9050 #: src/screens/Moderation/index.tsx:403 8952 9051 msgid "Toggle to enable or disable adult content" 8953 9052 msgstr "" ··· 9004 9103 msgid "Try again" 9005 9104 msgstr "" 9006 9105 9007 - #: src/screens/Onboarding/state.ts:118 9106 + #: src/screens/Onboarding/state.ts:123 9008 9107 msgid "TV" 9009 9108 msgstr "" 9010 9109 ··· 9016 9115 msgid "Type your message here" 9017 9116 msgstr "" 9018 9117 9019 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:432 9118 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:442 9020 9119 msgid "Type:" 9021 9120 msgstr "" 9022 9121 ··· 9126 9225 msgid "Unfortunately, none of your subscribed labelers supports this report type." 9127 9226 msgstr "" 9128 9227 9129 - #: src/components/verification/VerificationsDialog.tsx:206 9228 + #: src/components/verification/VerificationsDialog.tsx:211 9130 9229 msgid "Unknown verifier" 9131 9230 msgstr "" 9132 9231 ··· 9262 9361 msgid "Update email" 9263 9362 msgstr "" 9264 9363 9265 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:526 9266 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:547 9364 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:536 9365 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:557 9267 9366 msgid "Update to {domain}" 9268 9367 msgstr "" 9269 9368 ··· 9289 9388 msgid "Upload a photo instead" 9290 9389 msgstr "" 9291 9390 9292 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:472 9391 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:482 9293 9392 msgid "Upload a text file to:" 9294 9393 msgstr "" 9295 9394 ··· 9329 9428 msgid "Use app passwords to sign in to other Bluesky clients without giving full access to your account or password." 9330 9429 msgstr "" 9331 9430 9332 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:558 9431 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:568 9333 9432 msgid "Use default provider" 9334 9433 msgstr "" 9335 9434 ··· 9449 9548 msgid "Users you follow" 9450 9549 msgstr "" 9451 9550 9452 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:438 9551 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:448 9453 9552 msgid "Value:" 9454 9553 msgstr "" 9455 9554 ··· 9493 9592 msgid "Verify code" 9494 9593 msgstr "" 9495 9594 9496 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:528 9497 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:549 9595 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:538 9596 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:559 9498 9597 msgid "Verify DNS Record" 9499 9598 msgstr "" 9500 9599 ··· 9512 9611 msgid "Verify now" 9513 9612 msgstr "" 9514 9613 9515 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:529 9516 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:551 9614 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:539 9615 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:561 9517 9616 msgid "Verify Text File" 9518 9617 msgstr "" 9519 9618 ··· 9561 9660 msgid "Video from {0}: {text}" 9562 9661 msgstr "" 9563 9662 9564 - #: src/screens/Onboarding/index.tsx:39 9565 - #: src/screens/Onboarding/state.ts:106 9663 + #: src/screens/Onboarding/index.tsx:53 9664 + #: src/screens/Onboarding/state.ts:111 9566 9665 #: src/screens/Search/modules/ExploreTrendingTopics.tsx:234 9567 9666 msgid "Video Games" 9568 9667 msgstr "" ··· 9792 9891 msgid "We have sent another verification email to <0>{0}</0>." 9793 9892 msgstr "" 9794 9893 9795 - #: src/screens/Onboarding/StepFinished.tsx:256 9894 + #: src/screens/Onboarding/StepFinished.tsx:533 9796 9895 msgid "We hope you have a wonderful time. Remember, Bluesky is:" 9797 9896 msgstr "" 9798 9897 ··· 9812 9911 msgid "We were unable to determine if you are allowed to upload videos. Please try again." 9813 9912 msgstr "" 9814 9913 9815 - #: src/components/dialogs/BirthDateSettings.tsx:52 9914 + #: src/components/dialogs/BirthDateSettings.tsx:57 9816 9915 msgid "We were unable to load your birth date preferences. Please try again." 9817 9916 msgstr "" 9818 9917 ··· 9857 9956 msgstr "" 9858 9957 9859 9958 #: src/components/dialogs/SearchablePeopleList.tsx:107 9860 - #: src/components/ProgressGuide/FollowDialog.tsx:172 9959 + #: src/components/ProgressGuide/FollowDialog.tsx:164 9861 9960 msgid "We're having network issues, try again" 9862 9961 msgstr "" 9863 9962 ··· 9940 10039 msgid "When you tap on a check, you’ll see which organizations have granted verification." 9941 10040 msgstr "" 9942 10041 9943 - #: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:80 9944 - msgid "Which languages are used in this post?" 9945 - msgstr "" 9946 - 9947 10042 #: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:79 9948 10043 msgid "Which languages would you like to see in your algorithmic feeds?" 9949 10044 msgstr "" ··· 10023 10118 msgid "Write your reply" 10024 10119 msgstr "" 10025 10120 10026 - #: src/screens/Onboarding/index.tsx:25 10027 - #: src/screens/Onboarding/state.ts:119 10121 + #: src/screens/Onboarding/index.tsx:39 10122 + #: src/screens/Onboarding/state.ts:124 10028 10123 msgid "Writers" 10029 10124 msgstr "" 10030 10125 10031 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:356 10126 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:366 10032 10127 msgid "Wrong DID returned from server. Received: {0}" 10033 10128 msgstr "" 10034 10129 ··· 10258 10353 msgid "You have no lists." 10259 10354 msgstr "" 10260 10355 10261 - #: src/components/dialogs/StarterPackDialog.tsx:103 10356 + #: src/components/dialogs/StarterPackDialog.tsx:102 10262 10357 msgid "You have no starter packs." 10263 10358 msgstr "" 10264 10359 ··· 10319 10414 msgid "You must be 13 years of age or older to create an account." 10320 10415 msgstr "" 10321 10416 10417 + #: src/components/dialogs/BirthDateSettings.tsx:131 10418 + msgid "You must be at least 13 years old to use Bluesky. Read our <0>Terms of Service</0> for more information." 10419 + msgstr "" 10420 + 10322 10421 #: src/components/StarterPack/ProfileStarterPacks.tsx:334 10323 10422 msgid "You must be following at least seven other people to generate a starter pack." 10324 10423 msgstr "" ··· 10429 10528 msgid "You're in line" 10430 10529 msgstr "" 10431 10530 10432 - #: src/screens/Onboarding/StepFinished.tsx:253 10531 + #: src/screens/Onboarding/StepFinished.tsx:530 10433 10532 msgid "You're ready to go!" 10434 10533 msgstr "" 10435 10534 ··· 10519 10618 msgid "Your choice will be remembered for future links. You can change it at any time in settings." 10520 10619 msgstr "" 10521 10620 10522 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:513 10621 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:523 10523 10622 msgid "Your current handle <0>{0}</0> will automatically remain reserved for you. You can switch back to it at any time from this account." 10524 10623 msgstr "" 10525 10624 ··· 10556 10655 msgid "Your following feed is empty! Follow more users to see what's happening." 10557 10656 msgstr "" 10558 10657 10559 - #: src/screens/Settings/components/ChangeHandleDialog.tsx:240 10658 + #: src/screens/Settings/components/ChangeHandleDialog.tsx:245 10560 10659 msgid "Your full handle will be <0>@{0}</0>" 10561 10660 msgstr "" 10562 10661 ··· 10597 10696 msgid "Your posts were sent" 10598 10697 msgstr "" 10599 10698 10600 - #: src/screens/Onboarding/StepFinished.tsx:268 10699 + #: src/screens/Onboarding/StepFinished.tsx:545 10601 10700 msgid "Your posts, likes, and blocks are public. Mutes are private." 10602 10701 msgstr "" 10603 10702 10604 10703 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:290 10605 10704 msgid "Your preferred language" 10705 + msgstr "" 10706 + 10707 + #: src/screens/Onboarding/StepFinished.tsx:422 10708 + msgid "Your profile picture" 10709 + msgstr "" 10710 + 10711 + #: src/screens/Onboarding/StepFinished.tsx:350 10712 + msgid "Your profile picture surrounded by concentric circles of other users' profile pictures" 10606 10713 msgstr "" 10607 10714 10608 10715 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:75
+18 -1
src/logger/metrics.ts
··· 90 90 selectedInterests: string[] 91 91 selectedInterestsLength: number 92 92 } 93 + 'onboarding:suggestedAccounts:tabPressed': { 94 + tab: string 95 + } 96 + 'onboarding:suggestedAccounts:followAllPressed': { 97 + tab: string 98 + numAccounts: number 99 + } 93 100 'onboarding:suggestedAccounts:nextPressed': { 94 101 selectedAccountsLength: number 95 102 skipped: boolean ··· 118 125 'onboarding:finished:avatarResult': { 119 126 avatarResult: 'default' | 'created' | 'uploaded' 120 127 } 128 + 'onboarding:valueProp:stepOne:nextPressed': {} 129 + 'onboarding:valueProp:stepTwo:nextPressed': {} 130 + 'onboarding:valueProp:skipPressed': {} 121 131 'home:feedDisplayed': { 122 132 feedUrl: string 123 133 feedType: string ··· 242 252 | 'PostOnboardingFindFollows' 243 253 | 'ImmersiveVideo' 244 254 | 'ExploreSuggestedAccounts' 255 + | 'OnboardingSuggestedAccounts' 245 256 } 246 257 'suggestedUser:follow': { 247 258 logContext: ··· 249 260 | 'InterstitialDiscover' 250 261 | 'InterstitialProfile' 251 262 | 'Profile' 263 + | 'Onboarding' 252 264 location: 'Card' | 'Profile' 253 265 recId?: number 254 266 position: number 255 267 } 256 268 'suggestedUser:press': { 257 - logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile' 269 + logContext: 270 + | 'Explore' 271 + | 'InterstitialDiscover' 272 + | 'InterstitialProfile' 273 + | 'Onboarding' 258 274 recId?: number 259 275 position: number 260 276 } ··· 280 296 | 'PostOnboardingFindFollows' 281 297 | 'ImmersiveVideo' 282 298 | 'ExploreSuggestedAccounts' 299 + | 'OnboardingSuggestedAccounts' 283 300 } 284 301 'chat:create': { 285 302 logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
+6 -1
src/screens/Moderation/VerificationSettings.tsx
··· 44 44 <InlineLinkText 45 45 overridePresentation 46 46 to={urls.website.blog.initialVerificationAnnouncement} 47 - label={_(msg`Learn more`)} 47 + label={_( 48 + msg({ 49 + message: `Learn more`, 50 + context: `english-only-resource`, 51 + }), 52 + )} 48 53 onPress={() => { 49 54 logger.metric( 50 55 'verification:learn-more',
+76 -50
src/screens/Onboarding/Layout.tsx
··· 1 - import React from 'react' 1 + import React, {useState} from 'react' 2 2 import {ScrollView, View} from 'react-native' 3 3 import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 4 import {msg} from '@lingui/macro' ··· 11 11 atoms as a, 12 12 flatten, 13 13 native, 14 - TextStyleProp, 14 + type TextStyleProp, 15 + tokens, 15 16 useBreakpoints, 16 17 useTheme, 17 18 web, 18 19 } from '#/alf' 19 20 import {leading} from '#/alf/typography' 20 21 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 21 - import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' 22 + import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' 23 + import {HEADER_SLOT_SIZE} from '#/components/Layout' 22 24 import {createPortalGroup} from '#/components/Portal' 23 25 import {P, Text} from '#/components/Typography' 24 26 25 - const COL_WIDTH = 420 27 + const ONBOARDING_COL_WIDTH = 420 26 28 27 29 export const OnboardingControls = createPortalGroup() 30 + export const OnboardingHeaderSlot = createPortalGroup() 28 31 29 32 export function Layout({children}: React.PropsWithChildren<{}>) { 30 33 const {_} = useLingui() ··· 46 49 const paddingTop = gtMobile ? a.py_5xl : a.py_lg 47 50 const dialogLabel = _(msg`Set up your account`) 48 51 52 + const [footerHeight, setFooterHeight] = useState(0) 53 + 49 54 return ( 50 55 <View 51 56 aria-modal ··· 62 67 t.atoms.bg, 63 68 ]}> 64 69 {__DEV__ && ( 65 - <View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}> 66 - <Button 67 - variant="ghost" 68 - color="negative" 69 - size="small" 70 - onPress={() => onboardDispatch({type: 'skip'})} 71 - // DEV ONLY 72 - label="Clear onboarding state"> 73 - <ButtonText>Clear</ButtonText> 74 - </Button> 75 - </View> 70 + <Button 71 + variant="ghost" 72 + color="negative" 73 + size="tiny" 74 + onPress={() => onboardDispatch({type: 'skip'})} 75 + // DEV ONLY 76 + label="Clear onboarding state" 77 + style={[ 78 + a.absolute, 79 + a.z_10, 80 + { 81 + left: '50%', 82 + top: insets.top + 2, 83 + transform: [{translateX: '-50%'}], 84 + }, 85 + ]}> 86 + <ButtonText>[DEV] Clear</ButtonText> 87 + </Button> 76 88 )} 77 89 78 - {!gtMobile && state.hasPrev && ( 90 + {!gtMobile && ( 79 91 <View 92 + pointerEvents="box-none" 80 93 style={[ 81 94 web(a.fixed), 82 95 native(a.absolute), 96 + a.left_0, 97 + a.right_0, 83 98 a.flex_row, 84 99 a.w_full, 85 100 a.justify_center, 86 101 a.z_20, 87 102 a.px_xl, 88 - { 89 - top: paddingTop.paddingTop + insets.top - 1, 90 - }, 103 + {top: paddingTop.paddingTop + insets.top - 1}, 91 104 ]}> 92 - <View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}> 93 - <Button 94 - key={state.activeStep} // remove focus state on nav 95 - variant="ghost" 96 - color="secondary" 97 - size="small" 98 - shape="round" 99 - label={_(msg`Go back to previous step`)} 100 - style={[a.absolute]} 101 - onPress={() => dispatch({type: 'prev'})}> 102 - <ButtonIcon icon={ChevronLeft} /> 103 - </Button> 105 + <View 106 + pointerEvents="box-none" 107 + style={[ 108 + a.w_full, 109 + a.align_start, 110 + a.flex_row, 111 + a.justify_between, 112 + {maxWidth: ONBOARDING_COL_WIDTH}, 113 + ]}> 114 + {state.hasPrev ? ( 115 + <Button 116 + key={state.activeStep} // remove focus state on nav 117 + color="secondary" 118 + variant="ghost" 119 + shape="square" 120 + size="small" 121 + label={_(msg`Go back to previous step`)} 122 + onPress={() => dispatch({type: 'prev'})} 123 + style={[a.bg_transparent]}> 124 + <ButtonIcon icon={ArrowLeft} size="lg" /> 125 + </Button> 126 + ) : ( 127 + <View /> 128 + )} 129 + 130 + <OnboardingHeaderSlot.Outlet /> 104 131 </View> 105 132 </View> 106 133 )} ··· 109 136 ref={scrollview} 110 137 style={[a.h_full, a.w_full, {paddingTop: insets.top}]} 111 138 contentContainerStyle={{borderWidth: 0}} 112 - // @ts-ignore web only --prf 139 + scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}} 140 + // @ts-expect-error web only --prf 113 141 dataSet={{'stable-gutters': 1}}> 114 142 <View 115 143 style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}> 116 - <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}> 144 + <View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}> 117 145 <View style={[a.w_full, a.align_center, paddingTop]}> 118 146 <View 119 147 style={[ 120 148 a.flex_row, 121 149 a.gap_sm, 122 150 a.w_full, 123 - {paddingTop: 17, maxWidth: '60%'}, 151 + a.align_center, 152 + {height: HEADER_SLOT_SIZE, maxWidth: '60%'}, 124 153 ]}> 125 154 {Array(state.totalSteps) 126 155 .fill(0) 127 - .map((_, i) => ( 156 + .map((__, i) => ( 128 157 <View 129 158 key={i} 130 159 style={[ ··· 144 173 </View> 145 174 </View> 146 175 147 - <View 148 - style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}> 149 - {children} 150 - </View> 176 + <View style={[a.w_full, a.mb_5xl, a.pt_md]}>{children}</View> 151 177 152 - <View style={{height: 400}} /> 178 + <View style={{height: 100 + footerHeight}} /> 153 179 </View> 154 180 </View> 155 181 </ScrollView> 156 182 157 183 <View 184 + onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)} 158 185 style={[ 159 - // @ts-ignore web only -prf 160 186 isWeb ? a.fixed : a.absolute, 161 187 {bottom: 0, left: 0, right: 0}, 162 188 t.atoms.bg, ··· 167 193 isWeb 168 194 ? a.py_2xl 169 195 : { 170 - paddingTop: a.pt_lg.paddingTop, 171 - paddingBottom: insets.bottom + 10, 196 + paddingTop: tokens.space.md, 197 + paddingBottom: insets.bottom + tokens.space.md, 172 198 }, 173 199 ]}> 174 200 <View 175 201 style={[ 176 202 a.w_full, 177 - {maxWidth: COL_WIDTH}, 178 - gtMobile && [a.flex_row, a.justify_between], 203 + {maxWidth: ONBOARDING_COL_WIDTH}, 204 + gtMobile && [a.flex_row, a.justify_between, a.align_center], 179 205 ]}> 180 206 {gtMobile && 181 207 (state.hasPrev ? ( 182 208 <Button 183 209 key={state.activeStep} // remove focus state on nav 184 - variant="solid" 185 210 color="secondary" 186 - size="large" 187 - shape="round" 211 + variant="ghost" 212 + shape="square" 213 + size="small" 188 214 label={_(msg`Go back to previous step`)} 189 215 onPress={() => dispatch({type: 'prev'})}> 190 - <ButtonIcon icon={ChevronLeft} /> 216 + <ButtonIcon icon={ArrowLeft} size="lg" /> 191 217 </Button> 192 218 ) : ( 193 - <View style={{height: 54}} /> 219 + <View style={{height: 33}} /> 194 220 ))} 195 221 <OnboardingControls.Outlet /> 196 222 </View>
+284 -8
src/screens/Onboarding/StepFinished.tsx
··· 1 - import React from 'react' 1 + import {useCallback, useContext, useState} from 'react' 2 2 import {View} from 'react-native' 3 + import Animated, { 4 + Easing, 5 + LayoutAnimationConfig, 6 + SlideInRight, 7 + SlideOutLeft, 8 + } from 'react-native-reanimated' 9 + import {Image} from 'expo-image' 3 10 import { 4 11 type AppBskyActorDefs, 5 12 type AppBskyActorProfile, ··· 22 29 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 23 30 import {logEvent, useGate} from '#/lib/statsig/statsig' 24 31 import {logger} from '#/logger' 32 + import {isNative} from '#/platform/detection' 25 33 import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 26 34 import {getAllListMembers} from '#/state/queries/list-members' 27 35 import {preferencesQueryKey} from '#/state/queries/preferences' ··· 36 44 import { 37 45 DescriptionText, 38 46 OnboardingControls, 47 + OnboardingHeaderSlot, 39 48 TitleText, 40 49 } from '#/screens/Onboarding/Layout' 41 - import {Context} from '#/screens/Onboarding/state' 50 + import {Context, type OnboardingState} from '#/screens/Onboarding/state' 42 51 import {bulkWriteFollows} from '#/screens/Onboarding/util' 43 - import {atoms as a, useTheme} from '#/alf' 52 + import { 53 + atoms as a, 54 + native, 55 + platform, 56 + tokens, 57 + useBreakpoints, 58 + useTheme, 59 + } from '#/alf' 44 60 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 45 61 import {IconCircle} from '#/components/IconCircle' 62 + import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 46 63 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 47 64 import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' 48 65 import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' ··· 53 70 54 71 export function StepFinished() { 55 72 const {_} = useLingui() 56 - const t = useTheme() 57 - const {state, dispatch} = React.useContext(Context) 73 + const {state, dispatch} = useContext(Context) 58 74 const onboardDispatch = useOnboardingDispatch() 59 - const [saving, setSaving] = React.useState(false) 75 + const [saving, setSaving] = useState(false) 60 76 const queryClient = useQueryClient() 61 77 const agent = useAgent() 62 78 const requestNotificationsPermission = useRequestNotificationsPermission() ··· 66 82 const {startProgressGuide} = useProgressGuideControls() 67 83 const gate = useGate() 68 84 69 - const finishOnboarding = React.useCallback(async () => { 85 + const finishOnboarding = useCallback(async () => { 70 86 setSaving(true) 71 87 72 88 let starterPack: AppBskyGraphDefs.StarterPackView | undefined ··· 245 261 gate, 246 262 ]) 247 263 264 + return state.experiments?.onboarding_value_prop ? ( 265 + <ValueProposition 266 + finishOnboarding={finishOnboarding} 267 + saving={saving} 268 + state={state} 269 + /> 270 + ) : ( 271 + <LegacyFinalStep 272 + finishOnboarding={finishOnboarding} 273 + saving={saving} 274 + state={state} 275 + /> 276 + ) 277 + } 278 + 279 + const PROP_1 = { 280 + light: platform({ 281 + native: require('../../../assets/images/onboarding/value_prop_1_light.webp'), 282 + web: require('../../../assets/images/onboarding/value_prop_1_light_borderless.webp'), 283 + }), 284 + dim: platform({ 285 + native: require('../../../assets/images/onboarding/value_prop_1_dim.webp'), 286 + web: require('../../../assets/images/onboarding/value_prop_1_dim_borderless.webp'), 287 + }), 288 + dark: platform({ 289 + native: require('../../../assets/images/onboarding/value_prop_1_dark.webp'), 290 + web: require('../../../assets/images/onboarding/value_prop_1_dark_borderless.webp'), 291 + }), 292 + } as const 293 + 294 + const PROP_2 = { 295 + light: require('../../../assets/images/onboarding/value_prop_2_light.webp'), 296 + dim: require('../../../assets/images/onboarding/value_prop_2_dim.webp'), 297 + dark: require('../../../assets/images/onboarding/value_prop_2_dark.webp'), 298 + } as const 299 + 300 + const PROP_3 = { 301 + light: require('../../../assets/images/onboarding/value_prop_3_light.webp'), 302 + dim: require('../../../assets/images/onboarding/value_prop_3_dim.webp'), 303 + dark: require('../../../assets/images/onboarding/value_prop_3_dark.webp'), 304 + } as const 305 + 306 + function ValueProposition({ 307 + finishOnboarding, 308 + saving, 309 + state, 310 + }: { 311 + finishOnboarding: () => void 312 + saving: boolean 313 + state: OnboardingState 314 + }) { 315 + const [subStep, setSubStep] = useState<0 | 1 | 2>(0) 316 + const t = useTheme() 317 + const {_} = useLingui() 318 + const {gtMobile} = useBreakpoints() 319 + 320 + const image = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]][subStep] 321 + 322 + const onPress = () => { 323 + if (subStep === 2) { 324 + finishOnboarding() // has its own metrics 325 + } else if (subStep === 1) { 326 + setSubStep(2) 327 + logger.metric('onboarding:valueProp:stepTwo:nextPressed', {}) 328 + } else if (subStep === 0) { 329 + setSubStep(1) 330 + logger.metric('onboarding:valueProp:stepOne:nextPressed', {}) 331 + } 332 + } 333 + 334 + const {title, description, alt} = [ 335 + { 336 + title: _(msg`Free your feed`), 337 + description: _( 338 + msg`No more doomscrolling junk-filled algorithms. Find feeds that work for you, not against you.`, 339 + ), 340 + alt: _( 341 + msg`A collection of popular feeds you can find on Bluesky, including News, Booksky, Game Dev, Blacksky, and Fountain Pens`, 342 + ), 343 + }, 344 + { 345 + title: _(msg`Find your people`), 346 + description: _( 347 + msg`Ditch the trolls and clickbait. Find real people and conversations that matter to you.`, 348 + ), 349 + alt: _( 350 + msg`Your profile picture surrounded by concentric circles of other users' profile pictures`, 351 + ), 352 + }, 353 + { 354 + title: _(msg`Forget the noise`), 355 + description: _( 356 + msg`No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention.`, 357 + ), 358 + alt: _( 359 + msg`An illustration of several Bluesky posts alongside repost, like, and comment icons`, 360 + ), 361 + }, 362 + ][subStep] 363 + 364 + return ( 365 + <> 366 + {!gtMobile && ( 367 + <OnboardingHeaderSlot.Portal> 368 + <Button 369 + disabled={saving} 370 + variant="ghost" 371 + color="secondary" 372 + size="small" 373 + label={_(msg`Skip introduction and start using your account`)} 374 + onPress={() => { 375 + logger.metric('onboarding:valueProp:skipPressed', {}) 376 + finishOnboarding() 377 + }} 378 + style={[a.bg_transparent]}> 379 + <ButtonText> 380 + <Trans>Skip</Trans> 381 + </ButtonText> 382 + </Button> 383 + </OnboardingHeaderSlot.Portal> 384 + )} 385 + 386 + <LayoutAnimationConfig skipEntering skipExiting> 387 + <Animated.View 388 + key={subStep} 389 + entering={native( 390 + SlideInRight.easing(Easing.out(Easing.exp)).duration(500), 391 + )} 392 + exiting={native( 393 + SlideOutLeft.easing(Easing.out(Easing.exp)).duration(500), 394 + )}> 395 + <View 396 + style={[ 397 + a.relative, 398 + a.align_center, 399 + a.justify_center, 400 + isNative && {marginHorizontal: tokens.space.xl * -1}, 401 + a.pointer_events_none, 402 + ]}> 403 + <Image 404 + source={image} 405 + style={[a.w_full, {aspectRatio: 1}]} 406 + alt={alt} 407 + accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background 408 + /> 409 + {subStep === 1 && ( 410 + <Image 411 + source={state.profileStepResults.imageUri} 412 + style={[ 413 + a.z_10, 414 + a.absolute, 415 + a.rounded_full, 416 + { 417 + width: `${(80 / 393) * 100}%`, 418 + height: `${(80 / 393) * 100}%`, 419 + }, 420 + ]} 421 + accessibilityIgnoresInvertColors 422 + alt={_(msg`Your profile picture`)} 423 + /> 424 + )} 425 + </View> 426 + 427 + <View style={[a.mt_4xl, a.gap_2xl, a.align_center]}> 428 + <View style={[a.flex_row, a.gap_sm]}> 429 + <Dot active={subStep === 0} /> 430 + <Dot active={subStep === 1} /> 431 + <Dot active={subStep === 2} /> 432 + </View> 433 + 434 + <View style={[a.gap_sm]}> 435 + <Text style={[a.font_heavy, a.text_3xl, a.text_center]}> 436 + {title} 437 + </Text> 438 + <Text 439 + style={[ 440 + t.atoms.text_contrast_medium, 441 + a.text_md, 442 + a.leading_snug, 443 + a.text_center, 444 + ]}> 445 + {description} 446 + </Text> 447 + </View> 448 + </View> 449 + </Animated.View> 450 + </LayoutAnimationConfig> 451 + 452 + <OnboardingControls.Portal> 453 + <View style={gtMobile && [a.gap_md, a.flex_row]}> 454 + {gtMobile && ( 455 + <Button 456 + disabled={saving} 457 + color="secondary" 458 + size="large" 459 + label={_(msg`Skip introduction and start using your account`)} 460 + onPress={() => finishOnboarding()}> 461 + <ButtonText> 462 + <Trans>Skip</Trans> 463 + </ButtonText> 464 + </Button> 465 + )} 466 + <Button 467 + disabled={saving} 468 + key={state.activeStep} // remove focus state on nav 469 + color="primary" 470 + size="large" 471 + label={ 472 + subStep === 2 473 + ? _(msg`Complete onboarding and start using your account`) 474 + : _(msg`Next`) 475 + } 476 + onPress={onPress}> 477 + <ButtonText> 478 + {saving ? ( 479 + <Trans>Finalizing</Trans> 480 + ) : subStep === 2 ? ( 481 + <Trans>Let's go!</Trans> 482 + ) : ( 483 + <Trans>Next</Trans> 484 + )} 485 + </ButtonText> 486 + {subStep === 2 && ( 487 + <ButtonIcon icon={saving ? Loader : ArrowRight} /> 488 + )} 489 + </Button> 490 + </View> 491 + </OnboardingControls.Portal> 492 + </> 493 + ) 494 + } 495 + 496 + function Dot({active}: {active: boolean}) { 497 + const t = useTheme() 498 + const {_} = useLingui() 499 + 500 + return ( 501 + <View 502 + style={[ 503 + a.rounded_full, 504 + {width: 8, height: 8}, 505 + active 506 + ? {backgroundColor: t.palette.primary_500} 507 + : t.atoms.bg_contrast_50, 508 + ]} 509 + /> 510 + ) 511 + } 512 + 513 + function LegacyFinalStep({ 514 + finishOnboarding, 515 + saving, 516 + state, 517 + }: { 518 + finishOnboarding: () => void 519 + saving: boolean 520 + state: OnboardingState 521 + }) { 522 + const t = useTheme() 523 + const {_} = useLingui() 524 + 248 525 return ( 249 526 <View style={[a.align_start]}> 250 527 <IconCircle icon={Check} style={[a.mb_2xl]} /> ··· 305 582 <Button 306 583 disabled={saving} 307 584 key={state.activeStep} // remove focus state on nav 308 - variant="solid" 309 585 color="primary" 310 586 size="large" 311 587 label={_(msg`Complete onboarding and start using your account`)}
+10 -1
src/screens/Onboarding/StepInterests/index.tsx
··· 160 160 161 161 <View style={[a.w_full, a.pt_2xl]}> 162 162 {isLoading ? ( 163 - <Loader size="xl" /> 163 + <View 164 + style={[ 165 + a.flex_1, 166 + a.mt_md, 167 + a.align_center, 168 + a.justify_center, 169 + {minHeight: 400}, 170 + ]}> 171 + <Loader size="xl" /> 172 + </View> 164 173 ) : isError || !data ? ( 165 174 <View 166 175 style={[
+1 -1
src/screens/Onboarding/StepProfile/index.tsx
··· 266 266 </View> 267 267 268 268 <OnboardingControls.Portal> 269 - <View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}> 269 + <View style={[a.gap_md, gtMobile && a.flex_row_reverse]}> 270 270 <Button 271 271 variant="solid" 272 272 color="primary"
+356
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 1 + import {useCallback, useContext, useMemo, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {type ModerationOpts} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useMutation, useQueryClient} from '@tanstack/react-query' 7 + import * as bcp47Match from 'bcp-47-match' 8 + 9 + import {wait} from '#/lib/async/wait' 10 + import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' 11 + import {logger} from '#/logger' 12 + import {isWeb} from '#/platform/detection' 13 + import {updateProfileShadow} from '#/state/cache/profile-shadow' 14 + import {useLanguagePrefs} from '#/state/preferences' 15 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 + import {useAgent, useSession} from '#/state/session' 17 + import {useOnboardingDispatch} from '#/state/shell' 18 + import {OnboardingControls} from '#/screens/Onboarding/Layout' 19 + import { 20 + Context, 21 + popularInterests, 22 + useInterestsDisplayNames, 23 + } from '#/screens/Onboarding/state' 24 + import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' 25 + import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' 26 + import {Admonition} from '#/components/Admonition' 27 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 28 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise' 29 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 30 + import {boostInterests, InterestTabs} from '#/components/InterestTabs' 31 + import {Loader} from '#/components/Loader' 32 + import * as ProfileCard from '#/components/ProfileCard' 33 + import * as toast from '#/components/Toast' 34 + import {Text} from '#/components/Typography' 35 + import type * as bsky from '#/types/bsky' 36 + import {bulkWriteFollows} from '../util' 37 + 38 + export function StepSuggestedAccounts() { 39 + const {_} = useLingui() 40 + const t = useTheme() 41 + const {gtMobile} = useBreakpoints() 42 + const moderationOpts = useModerationOpts() 43 + const agent = useAgent() 44 + const {currentAccount} = useSession() 45 + const queryClient = useQueryClient() 46 + 47 + const {state, dispatch} = useContext(Context) 48 + const onboardDispatch = useOnboardingDispatch() 49 + 50 + const [selectedInterest, setSelectedInterest] = useState<string | null>(null) 51 + // keeping track of who was followed via the follow all button 52 + // so we can enable/disable the button without having to dig through the shadow cache 53 + const [followedUsers, setFollowedUsers] = useState<string[]>([]) 54 + 55 + /* 56 + * Special language handling copied wholesale from the Explore screen 57 + */ 58 + const {contentLanguages} = useLanguagePrefs() 59 + const useFullExperience = useMemo(() => { 60 + if (contentLanguages.length === 0) return true 61 + return bcp47Match.basicFilter('en', contentLanguages).length > 0 62 + }, [contentLanguages]) 63 + const interestsDisplayNames = useInterestsDisplayNames() 64 + const interests = Object.keys(interestsDisplayNames) 65 + .sort(boostInterests(popularInterests)) 66 + .sort(boostInterests(state.interestsStepResults.selectedInterests)) 67 + const { 68 + data: suggestedUsers, 69 + isLoading, 70 + error, 71 + isRefetching, 72 + refetch, 73 + } = useSuggestedUsers({ 74 + category: selectedInterest || (useFullExperience ? null : interests[0]), 75 + search: !useFullExperience, 76 + overrideInterests: state.interestsStepResults.selectedInterests, 77 + }) 78 + 79 + const isError = !!error 80 + 81 + const skipOnboarding = useCallback(() => { 82 + onboardDispatch({type: 'finish'}) 83 + dispatch({type: 'finish'}) 84 + }, [onboardDispatch, dispatch]) 85 + 86 + const followableDids = 87 + suggestedUsers?.actors 88 + .filter( 89 + user => 90 + user.did !== currentAccount?.did && 91 + !isBlockedOrBlocking(user) && 92 + !isMuted(user) && 93 + !user.viewer?.following && 94 + !followedUsers.includes(user.did), 95 + ) 96 + .map(user => user.did) ?? [] 97 + 98 + const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 99 + onMutate: () => { 100 + logger.metric('onboarding:suggestedAccounts:followAllPressed', { 101 + tab: selectedInterest ?? 'all', 102 + numAccounts: followableDids.length, 103 + }) 104 + }, 105 + mutationFn: async () => { 106 + for (const did of followableDids) { 107 + updateProfileShadow(queryClient, did, { 108 + followingUri: 'pending', 109 + }) 110 + } 111 + const uris = await wait(1e3, bulkWriteFollows(agent, followableDids)) 112 + for (const did of followableDids) { 113 + const uri = uris.get(did) 114 + updateProfileShadow(queryClient, did, { 115 + followingUri: uri, 116 + }) 117 + } 118 + return followableDids 119 + }, 120 + onSuccess: newlyFollowed => { 121 + toast.show(_(msg`Followed all accounts!`), {type: 'success'}) 122 + setFollowedUsers(followed => [...followed, ...newlyFollowed]) 123 + }, 124 + onError: () => { 125 + toast.show( 126 + _(msg`Failed to follow all suggested accounts, please try again`), 127 + {type: 'error'}, 128 + ) 129 + }, 130 + }) 131 + 132 + const canFollowAll = followableDids.length > 0 && !isFollowingAll 133 + 134 + return ( 135 + <View style={[a.align_start]} testID="onboardingInterests"> 136 + <Text style={[a.font_heavy, a.text_3xl]}> 137 + <Trans comment="Accounts suggested to the user for them to follow"> 138 + Suggested for you 139 + </Trans> 140 + </Text> 141 + 142 + <View 143 + style={[ 144 + a.overflow_hidden, 145 + a.mt_lg, 146 + isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1}, 147 + a.flex_1, 148 + a.justify_start, 149 + ]}> 150 + <TabBar 151 + selectedInterest={selectedInterest} 152 + onSelectInterest={setSelectedInterest} 153 + defaultTabLabel={_( 154 + msg({ 155 + message: 'All', 156 + comment: 'the default tab in the interests tab bar', 157 + }), 158 + )} 159 + selectedInterests={state.interestsStepResults.selectedInterests} 160 + /> 161 + 162 + {isLoading || !moderationOpts ? ( 163 + <View 164 + style={[ 165 + a.flex_1, 166 + a.mt_md, 167 + a.align_center, 168 + a.justify_center, 169 + {minHeight: 400}, 170 + ]}> 171 + <Loader size="xl" /> 172 + </View> 173 + ) : isError ? ( 174 + <View style={[a.flex_1, a.px_xl, a.pt_5xl]}> 175 + <Admonition type="error"> 176 + <Trans> 177 + An error occurred while fetching suggested accounts. 178 + </Trans> 179 + </Admonition> 180 + </View> 181 + ) : ( 182 + <View 183 + style={[ 184 + a.flex_1, 185 + a.mt_md, 186 + a.border_y, 187 + t.atoms.border_contrast_low, 188 + isWeb && [a.border_x, a.rounded_sm, a.overflow_hidden], 189 + ]}> 190 + {suggestedUsers?.actors.map((user, index) => ( 191 + <SuggestedProfileCard 192 + key={user.did} 193 + profile={user} 194 + moderationOpts={moderationOpts} 195 + position={index} 196 + /> 197 + ))} 198 + </View> 199 + )} 200 + </View> 201 + 202 + <OnboardingControls.Portal> 203 + {isError ? ( 204 + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> 205 + <Button 206 + disabled={isRefetching} 207 + color="secondary" 208 + size="large" 209 + label={_(msg`Retry`)} 210 + onPress={() => refetch()}> 211 + <ButtonText> 212 + <Trans>Retry</Trans> 213 + </ButtonText> 214 + <ButtonIcon icon={ArrowRotateCounterClockwiseIcon} /> 215 + </Button> 216 + <Button 217 + color="secondary" 218 + size="large" 219 + label={_(msg`Skip this flow`)} 220 + onPress={skipOnboarding}> 221 + <ButtonText> 222 + <Trans>Skip</Trans> 223 + </ButtonText> 224 + </Button> 225 + </View> 226 + ) : ( 227 + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> 228 + <Button 229 + disabled={!canFollowAll} 230 + color="secondary" 231 + size="large" 232 + label={_(msg`Follow all accounts`)} 233 + onPress={() => followAll()}> 234 + <ButtonText> 235 + <Trans>Follow all</Trans> 236 + </ButtonText> 237 + <ButtonIcon icon={isFollowingAll ? Loader : PlusIcon} /> 238 + </Button> 239 + <Button 240 + disabled={isFollowingAll} 241 + color="primary" 242 + size="large" 243 + label={_(msg`Continue to next step`)} 244 + onPress={() => dispatch({type: 'next'})}> 245 + <ButtonText> 246 + <Trans>Continue</Trans> 247 + </ButtonText> 248 + </Button> 249 + </View> 250 + )} 251 + </OnboardingControls.Portal> 252 + </View> 253 + ) 254 + } 255 + 256 + function TabBar({ 257 + selectedInterest, 258 + onSelectInterest, 259 + selectedInterests, 260 + hideDefaultTab, 261 + defaultTabLabel, 262 + }: { 263 + selectedInterest: string | null 264 + onSelectInterest: (interest: string | null) => void 265 + selectedInterests: string[] 266 + hideDefaultTab?: boolean 267 + defaultTabLabel?: string 268 + }) { 269 + const {_} = useLingui() 270 + const interestsDisplayNames = useInterestsDisplayNames() 271 + const interests = Object.keys(interestsDisplayNames) 272 + .sort(boostInterests(popularInterests)) 273 + .sort(boostInterests(selectedInterests)) 274 + 275 + return ( 276 + <InterestTabs 277 + interests={hideDefaultTab ? interests : ['all', ...interests]} 278 + selectedInterest={ 279 + selectedInterest || (hideDefaultTab ? interests[0] : 'all') 280 + } 281 + onSelectTab={tab => { 282 + logger.metric( 283 + 'onboarding:suggestedAccounts:tabPressed', 284 + {tab: tab}, 285 + {statsig: true}, 286 + ) 287 + onSelectInterest(tab === 'all' ? null : tab) 288 + }} 289 + interestsDisplayNames={ 290 + hideDefaultTab 291 + ? interestsDisplayNames 292 + : { 293 + all: defaultTabLabel || _(msg`For You`), 294 + ...interestsDisplayNames, 295 + } 296 + } 297 + gutterWidth={isWeb ? 0 : tokens.space.xl} 298 + /> 299 + ) 300 + } 301 + 302 + function SuggestedProfileCard({ 303 + profile, 304 + moderationOpts, 305 + position, 306 + }: { 307 + profile: bsky.profile.AnyProfileView 308 + moderationOpts: ModerationOpts 309 + position: number 310 + }) { 311 + const t = useTheme() 312 + return ( 313 + <View 314 + style={[ 315 + a.flex_1, 316 + a.w_full, 317 + a.py_lg, 318 + a.px_xl, 319 + position !== 0 && a.border_t, 320 + t.atoms.border_contrast_low, 321 + ]}> 322 + <ProfileCard.Outer> 323 + <ProfileCard.Header> 324 + <ProfileCard.Avatar 325 + profile={profile} 326 + moderationOpts={moderationOpts} 327 + disabledPreview 328 + /> 329 + <ProfileCard.NameAndHandle 330 + profile={profile} 331 + moderationOpts={moderationOpts} 332 + /> 333 + <ProfileCard.FollowButton 334 + profile={profile} 335 + moderationOpts={moderationOpts} 336 + withIcon={false} 337 + logContext="OnboardingSuggestedAccounts" 338 + onFollow={() => { 339 + logger.metric( 340 + 'suggestedUser:follow', 341 + { 342 + logContext: 'Onboarding', 343 + location: 'Card', 344 + recId: undefined, 345 + position, 346 + }, 347 + {statsig: true}, 348 + ) 349 + }} 350 + /> 351 + </ProfileCard.Header> 352 + <ProfileCard.Description profile={profile} numberOfLines={3} /> 353 + </ProfileCard.Outer> 354 + </View> 355 + ) 356 + }
+34 -15
src/screens/Onboarding/index.tsx
··· 1 - import React from 'react' 1 + import {useMemo, useReducer} from 'react' 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout' 5 + import {useGate} from '#/lib/statsig/statsig' 6 + import { 7 + Layout, 8 + OnboardingControls, 9 + OnboardingHeaderSlot, 10 + } from '#/screens/Onboarding/Layout' 6 11 import {Context, initialState, reducer} from '#/screens/Onboarding/state' 7 12 import {StepFinished} from '#/screens/Onboarding/StepFinished' 8 13 import {StepInterests} from '#/screens/Onboarding/StepInterests' 9 14 import {StepProfile} from '#/screens/Onboarding/StepProfile' 10 15 import {Portal} from '#/components/Portal' 16 + import {StepSuggestedAccounts} from './StepSuggestedAccounts' 11 17 12 18 export function Onboarding() { 13 19 const {_} = useLingui() 14 - const [state, dispatch] = React.useReducer(reducer, { 20 + const gate = useGate() 21 + const showValueProp = gate('onboarding_value_prop') 22 + const showSuggestedAccounts = gate('onboarding_suggested_accounts') 23 + const [state, dispatch] = useReducer(reducer, { 15 24 ...initialState, 25 + totalSteps: showSuggestedAccounts ? 4 : 3, 26 + experiments: { 27 + onboarding_suggested_accounts: showSuggestedAccounts, 28 + onboarding_value_prop: showValueProp, 29 + }, 16 30 }) 17 31 18 - const interestsDisplayNames = React.useMemo(() => { 32 + const interestsDisplayNames = useMemo(() => { 19 33 return { 20 34 news: _(msg`News`), 21 35 journalism: _(msg`Journalism`), ··· 45 59 return ( 46 60 <Portal> 47 61 <OnboardingControls.Provider> 48 - <Context.Provider 49 - value={React.useMemo( 50 - () => ({state, dispatch, interestsDisplayNames}), 51 - [state, dispatch, interestsDisplayNames], 52 - )}> 53 - <Layout> 54 - {state.activeStep === 'profile' && <StepProfile />} 55 - {state.activeStep === 'interests' && <StepInterests />} 56 - {state.activeStep === 'finished' && <StepFinished />} 57 - </Layout> 58 - </Context.Provider> 62 + <OnboardingHeaderSlot.Provider> 63 + <Context.Provider 64 + value={useMemo( 65 + () => ({state, dispatch, interestsDisplayNames}), 66 + [state, dispatch, interestsDisplayNames], 67 + )}> 68 + <Layout> 69 + {state.activeStep === 'profile' && <StepProfile />} 70 + {state.activeStep === 'interests' && <StepInterests />} 71 + {state.activeStep === 'suggested-accounts' && ( 72 + <StepSuggestedAccounts /> 73 + )} 74 + {state.activeStep === 'finished' && <StepFinished />} 75 + </Layout> 76 + </Context.Provider> 77 + </OnboardingHeaderSlot.Provider> 59 78 </OnboardingControls.Provider> 60 79 </Portal> 61 80 )
+45 -13
src/screens/Onboarding/state.ts
··· 11 11 export type OnboardingState = { 12 12 hasPrev: boolean 13 13 totalSteps: number 14 - activeStep: 'profile' | 'interests' | 'finished' 14 + activeStep: 'profile' | 'interests' | 'suggested-accounts' | 'finished' 15 15 activeStepIndex: number 16 16 17 17 interestsStepResults: { ··· 33 33 emoji: Emoji 34 34 backgroundColor: AvatarColor 35 35 } 36 + } 37 + 38 + experiments?: { 39 + onboarding_suggested_accounts?: boolean 40 + onboarding_value_prop?: boolean 36 41 } 37 42 } 38 43 ··· 160 165 161 166 switch (a.type) { 162 167 case 'next': { 163 - if (s.activeStep === 'profile') { 164 - next.activeStep = 'interests' 165 - next.activeStepIndex = 2 166 - } else if (s.activeStep === 'interests') { 167 - next.activeStep = 'finished' 168 - next.activeStepIndex = 3 168 + if (s.experiments?.onboarding_suggested_accounts) { 169 + if (s.activeStep === 'profile') { 170 + next.activeStep = 'interests' 171 + next.activeStepIndex = 2 172 + } else if (s.activeStep === 'interests') { 173 + next.activeStep = 'suggested-accounts' 174 + next.activeStepIndex = 3 175 + } 176 + if (s.activeStep === 'suggested-accounts') { 177 + next.activeStep = 'finished' 178 + next.activeStepIndex = 4 179 + } 180 + } else { 181 + if (s.activeStep === 'profile') { 182 + next.activeStep = 'interests' 183 + next.activeStepIndex = 2 184 + } else if (s.activeStep === 'interests') { 185 + next.activeStep = 'finished' 186 + next.activeStepIndex = 3 187 + } 169 188 } 170 189 break 171 190 } 172 191 case 'prev': { 173 - if (s.activeStep === 'interests') { 174 - next.activeStep = 'profile' 175 - next.activeStepIndex = 1 176 - } else if (s.activeStep === 'finished') { 177 - next.activeStep = 'interests' 178 - next.activeStepIndex = 2 192 + if (s.experiments?.onboarding_suggested_accounts) { 193 + if (s.activeStep === 'interests') { 194 + next.activeStep = 'profile' 195 + next.activeStepIndex = 1 196 + } else if (s.activeStep === 'suggested-accounts') { 197 + next.activeStep = 'interests' 198 + next.activeStepIndex = 2 199 + } else if (s.activeStep === 'finished') { 200 + next.activeStep = 'suggested-accounts' 201 + next.activeStepIndex = 3 202 + } 203 + } else { 204 + if (s.activeStep === 'interests') { 205 + next.activeStep = 'profile' 206 + next.activeStepIndex = 1 207 + } else if (s.activeStep === 'finished') { 208 + next.activeStep = 'interests' 209 + next.activeStepIndex = 2 210 + } 179 211 } 180 212 break 181 213 }
+7 -7
src/screens/Onboarding/util.ts
··· 1 1 import { 2 - $Typed, 3 - AppBskyGraphFollow, 4 - AppBskyGraphGetFollows, 5 - BskyAgent, 6 - ComAtprotoRepoApplyWrites, 2 + type $Typed, 3 + type AppBskyGraphFollow, 4 + type AppBskyGraphGetFollows, 5 + type BskyAgent, 6 + type ComAtprotoRepoApplyWrites, 7 7 } from '@atproto/api' 8 8 import {TID} from '@atproto/common-web' 9 9 import chunk from 'lodash.chunk' ··· 42 42 } 43 43 await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length) 44 44 45 - const followUris = new Map() 45 + const followUris = new Map<string, string>() 46 46 for (const r of followWrites) { 47 47 followUris.set( 48 - r.value.subject, 48 + r.value.subject as string, 49 49 `at://${session.did}/app.bsky.graph.follow/${r.rkey}`, 50 50 ) 51 51 }
+7 -2
src/screens/Profile/Header/EditProfileDialog.tsx
··· 347 347 You are verified. You will lose your verification status if you 348 348 change your display name.{' '} 349 349 <InlineLinkText 350 - label={_(msg`Learn more`)} 350 + label={_( 351 + msg({ 352 + message: `Learn more`, 353 + context: `english-only-resource`, 354 + }), 355 + )} 351 356 to={urls.website.blog.initialVerificationAnnouncement}> 352 - <Trans>Learn more.</Trans> 357 + <Trans context="english-only-resource">Learn more.</Trans> 353 358 </InlineLinkText> 354 359 </Trans> 355 360 </Admonition>
+2 -1
src/screens/Profile/Header/SuggestedFollows.tsx
··· 28 28 actorDid: string 29 29 }) { 30 30 const gate = useGate() 31 - if (!gate('post_follow_profile_suggested_accounts')) return null 32 31 33 32 /* NOTE (caidanw): 34 33 * Android does not work well with this feature yet. ··· 36 35 * Blocking the ability to scroll on Android is too much of a trade-off for now. 37 36 **/ 38 37 if (isAndroid) return null 38 + 39 + if (!gate('post_follow_profile_suggested_accounts')) return null 39 40 40 41 return ( 41 42 <AccordionAnimation isExpanded={isExpanded}>
+1 -1
src/screens/Search/Explore.tsx
··· 66 66 import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 67 67 import {StarterPack} from '#/components/icons/StarterPack' 68 68 import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' 69 + import {boostInterests} from '#/components/InterestTabs' 69 70 import {Loader} from '#/components/Loader' 70 71 import * as ProfileCard from '#/components/ProfileCard' 71 - import {boostInterests} from '#/components/ProgressGuide/FollowDialog' 72 72 import {SubtleHover} from '#/components/SubtleHover' 73 73 import {Text} from '#/components/Typography' 74 74 import * as ModuleHeader from './components/ModuleHeader'
+6 -68
src/screens/Search/modules/ExploreSuggestedAccounts.tsx
··· 14 14 } from '#/screens/Onboarding/state' 15 15 import {useTheme} from '#/alf' 16 16 import {atoms as a} from '#/alf' 17 - import {Button} from '#/components/Button' 17 + import {boostInterests, InterestTabs} from '#/components/InterestTabs' 18 18 import * as ProfileCard from '#/components/ProfileCard' 19 - import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog' 20 19 import {SubtleHover} from '#/components/SubtleHover' 21 - import {Text} from '#/components/Typography' 22 20 import type * as bsky from '#/types/bsky' 23 21 24 22 export function useLoadEnoughProfiles({ ··· 59 57 selectedInterest, 60 58 onSelectInterest, 61 59 hideDefaultTab, 60 + defaultTabLabel, 62 61 }: { 63 62 selectedInterest: string | null 64 63 onSelectInterest: (interest: string | null) => void 65 64 hideDefaultTab?: boolean 65 + defaultTabLabel?: string 66 66 }) { 67 67 const {_} = useLingui() 68 68 const interestsDisplayNames = useInterestsDisplayNames() ··· 71 71 const interests = Object.keys(interestsDisplayNames) 72 72 .sort(boostInterests(popularInterests)) 73 73 .sort(boostInterests(personalizedInterests)) 74 + 74 75 return ( 75 76 <BlockDrawerGesture> 76 - <Tabs 77 + <InterestTabs 77 78 interests={hideDefaultTab ? interests : ['all', ...interests]} 78 79 selectedInterest={ 79 80 selectedInterest || (hideDefaultTab ? interests[0] : 'all') ··· 86 87 ) 87 88 onSelectInterest(tab === 'all' ? null : tab) 88 89 }} 89 - hasSearchText={false} 90 90 interestsDisplayNames={ 91 91 hideDefaultTab 92 92 ? interestsDisplayNames 93 93 : { 94 - all: _(msg`For You`), 94 + all: defaultTabLabel || _(msg`For You`), 95 95 ...interestsDisplayNames, 96 96 } 97 97 } 98 - TabComponent={Tab} 99 - contentContainerStyle={[ 100 - { 101 - // visual alignment 102 - paddingLeft: a.px_md.paddingLeft, 103 - }, 104 - ]} 105 98 /> 106 99 </BlockDrawerGesture> 107 100 ) 108 101 } 109 - 110 - let Tab = ({ 111 - onSelectTab, 112 - interest, 113 - active, 114 - index, 115 - interestsDisplayName, 116 - onLayout, 117 - }: { 118 - onSelectTab: (index: number) => void 119 - interest: string 120 - active: boolean 121 - index: number 122 - interestsDisplayName: string 123 - onLayout: (index: number, x: number, width: number) => void 124 - }): React.ReactNode => { 125 - const t = useTheme() 126 - const {_} = useLingui() 127 - const activeText = active ? _(msg` (active)`) : '' 128 - return ( 129 - <View 130 - key={interest} 131 - onLayout={e => 132 - onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 133 - }> 134 - <Button 135 - label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} 136 - onPress={() => onSelectTab(index)}> 137 - {({hovered, pressed, focused}) => ( 138 - <View 139 - style={[ 140 - a.rounded_full, 141 - a.px_lg, 142 - a.py_sm, 143 - a.border, 144 - active || hovered || pressed || focused 145 - ? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium] 146 - : [t.atoms.bg, t.atoms.border_contrast_low], 147 - ]}> 148 - <Text 149 - style={[ 150 - a.font_medium, 151 - active || hovered || pressed || focused 152 - ? t.atoms.text 153 - : t.atoms.text_contrast_medium, 154 - ]}> 155 - {interestsDisplayName} 156 - </Text> 157 - </View> 158 - )} 159 - </Button> 160 - </View> 161 - ) 162 - } 163 - Tab = memo(Tab) 164 102 165 103 /** 166 104 * Profile card for suggested accounts. Note: border is on the bottom edge
+9
src/screens/Search/util/useSuggestedUsers.ts
··· 11 11 export function useSuggestedUsers({ 12 12 category = null, 13 13 search = false, 14 + overrideInterests, 14 15 }: { 15 16 category?: string | null 16 17 /** ··· 18 19 * based on the user's "app language setting 19 20 */ 20 21 search?: boolean 22 + /** 23 + * In onboarding, interests haven't been saved to prefs yet, so we need to 24 + * pass them down through here 25 + */ 26 + overrideInterests?: string[] 21 27 }) { 22 28 const interestsDisplayNames = useInterestsDisplayNames() 23 29 const curated = useGetSuggestedUsersQuery({ 24 30 enabled: !search, 25 31 category, 32 + overrideInterests, 26 33 }) 27 34 const searched = useActorSearchPaginated({ 28 35 enabled: !!search, ··· 43 50 isLoading: searched.isLoading, 44 51 error: searched.error, 45 52 isRefetching: searched.isRefetching, 53 + refetch: searched.refetch, 46 54 } 47 55 } else { 48 56 return { ··· 50 58 isLoading: curated.isLoading, 51 59 error: curated.error, 52 60 isRefetching: curated.isRefetching, 61 + refetch: curated.refetch, 53 62 } 54 63 } 55 64 }, [curated, searched, search])
+13 -3
src/screens/Settings/components/ChangeHandleDialog.tsx
··· 209 209 You are verified. You will lose your verification status if you 210 210 change your handle.{' '} 211 211 <InlineLinkText 212 - label={_(msg`Learn more`)} 212 + label={_( 213 + msg({ 214 + message: `Learn more`, 215 + context: `english-only-resource`, 216 + }), 217 + )} 213 218 to={urls.website.blog.initialVerificationAnnouncement}> 214 - <Trans>Learn more.</Trans> 219 + <Trans context="english-only-resource">Learn more.</Trans> 215 220 </InlineLinkText> 216 221 </Trans> 217 222 </Admonition> ··· 268 273 If you have your own domain, you can use that as your handle. This 269 274 lets you self-verify your identity.{' '} 270 275 <InlineLinkText 271 - label={_(msg`learn more`)} 276 + label={_( 277 + msg({ 278 + message: `Learn more`, 279 + context: `english-only-resource`, 280 + }), 281 + )} 272 282 to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial" 273 283 style={[a.font_bold]} 274 284 disableMismatchWarning>
-5
src/state/modals/index.tsx
··· 35 35 name: 'content-languages-settings' 36 36 } 37 37 38 - export interface PostLanguagesSettingsModal { 39 - name: 'post-languages-settings' 40 - } 41 - 42 38 /** 43 39 * @deprecated DO NOT ADD NEW MODALS 44 40 */ ··· 48 44 49 45 // Curation 50 46 | ContentLanguagesSettingsModal 51 - | PostLanguagesSettingsModal 52 47 53 48 // Lists 54 49 | CreateOrEditListModal
+8 -1
src/state/queries/trending/useGetSuggestedUsersQuery.ts
··· 17 17 category?: string | null 18 18 limit?: number 19 19 enabled?: boolean 20 + overrideInterests?: string[] 20 21 } 21 22 22 23 export const getSuggestedUsersQueryKeyRoot = 'unspecced-suggested-users' ··· 24 25 getSuggestedUsersQueryKeyRoot, 25 26 props.category, 26 27 props.limit, 28 + props.overrideInterests?.join(','), 27 29 ] 28 30 29 31 export function useGetSuggestedUsersQuery(props: QueryProps) { ··· 36 38 queryKey: createGetSuggestedUsersQueryKey(props), 37 39 queryFn: async () => { 38 40 const contentLangs = getContentLanguages().join(',') 41 + const interests = aggregateUserInterests(preferences) 39 42 const {data} = await agent.app.bsky.unspecced.getSuggestedUsers( 40 43 { 41 44 category: props.category ?? undefined, ··· 43 46 }, 44 47 { 45 48 headers: { 46 - ...createBskyTopicsHeader(aggregateUserInterests(preferences)), 49 + ...createBskyTopicsHeader( 50 + props.overrideInterests && props.overrideInterests.length > 0 51 + ? props.overrideInterests.join(',') 52 + : interests, 53 + ), 47 54 'Accept-Language': contentLangs, 48 55 }, 49 56 },
+2 -2
src/view/com/composer/Composer.tsx
··· 110 110 import {Gallery} from '#/view/com/composer/photos/Gallery' 111 111 import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn' 112 112 import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn' 113 - import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn' 113 + import {SelectPostLanguagesBtn} from '#/view/com/composer/select-language/SelectPostLanguagesDialog' 114 114 import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage' 115 115 // TODO: Prevent naming components that coincide with RN primitives 116 116 // due to linting false positives ··· 1453 1453 /> 1454 1454 </Button> 1455 1455 )} 1456 - <SelectLangBtn /> 1456 + <SelectPostLanguagesBtn /> 1457 1457 <CharProgress 1458 1458 count={post.shortenedGraphemeLength} 1459 1459 style={{width: 65}}
-133
src/view/com/composer/select-language/SelectLangBtn.tsx
··· 1 - import {useCallback, useMemo} from 'react' 2 - import {Keyboard, StyleSheet} from 'react-native' 3 - import { 4 - FontAwesomeIcon, 5 - FontAwesomeIconStyle, 6 - } from '@fortawesome/react-native-fontawesome' 7 - import {msg} from '@lingui/macro' 8 - import {useLingui} from '@lingui/react' 9 - 10 - import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants' 11 - import {usePalette} from '#/lib/hooks/usePalette' 12 - import {isNative} from '#/platform/detection' 13 - import {useModalControls} from '#/state/modals' 14 - import { 15 - hasPostLanguage, 16 - toPostLanguages, 17 - useLanguagePrefs, 18 - useLanguagePrefsApi, 19 - } from '#/state/preferences/languages' 20 - import { 21 - DropdownButton, 22 - DropdownItem, 23 - DropdownItemButton, 24 - } from '#/view/com/util/forms/DropdownButton' 25 - import {Text} from '#/view/com/util/text/Text' 26 - import {codeToLanguageName} from '../../../../locale/helpers' 27 - 28 - export function SelectLangBtn() { 29 - const pal = usePalette('default') 30 - const {_} = useLingui() 31 - const {openModal} = useModalControls() 32 - const langPrefs = useLanguagePrefs() 33 - const setLangPrefs = useLanguagePrefsApi() 34 - 35 - const onPressMore = useCallback(async () => { 36 - if (isNative) { 37 - if (Keyboard.isVisible()) { 38 - Keyboard.dismiss() 39 - } 40 - } 41 - openModal({name: 'post-languages-settings'}) 42 - }, [openModal]) 43 - 44 - const postLanguagesPref = toPostLanguages(langPrefs.postLanguage) 45 - const items: DropdownItem[] = useMemo(() => { 46 - let arr: DropdownItemButton[] = [] 47 - 48 - function add(commaSeparatedLangCodes: string) { 49 - const langCodes = commaSeparatedLangCodes.split(',') 50 - const langName = langCodes 51 - .map(code => codeToLanguageName(code, langPrefs.appLanguage)) 52 - .join(' + ') 53 - 54 - /* 55 - * Filter out any duplicates 56 - */ 57 - if (arr.find((item: DropdownItemButton) => item.label === langName)) { 58 - return 59 - } 60 - 61 - arr.push({ 62 - icon: 63 - langCodes.every(code => 64 - hasPostLanguage(langPrefs.postLanguage, code), 65 - ) && langCodes.length === postLanguagesPref.length 66 - ? ['fas', 'circle-dot'] 67 - : ['far', 'circle'], 68 - label: langName, 69 - onPress() { 70 - setLangPrefs.setPostLanguage(commaSeparatedLangCodes) 71 - }, 72 - }) 73 - } 74 - 75 - if (postLanguagesPref.length) { 76 - /* 77 - * Re-join here after sanitization bc postLanguageHistory is an array of 78 - * comma-separated strings too 79 - */ 80 - add(langPrefs.postLanguage) 81 - } 82 - 83 - // comma-separted strings of lang codes that have been used in the past 84 - for (const lang of langPrefs.postLanguageHistory) { 85 - add(lang) 86 - } 87 - 88 - return [ 89 - {heading: true, label: _(msg`Post language`)}, 90 - ...arr.slice(0, 6), 91 - {sep: true}, 92 - { 93 - label: _(msg`Other...`), 94 - onPress: onPressMore, 95 - }, 96 - ] 97 - }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref, _]) 98 - 99 - return ( 100 - <DropdownButton 101 - type="bare" 102 - testID="selectLangBtn" 103 - items={items} 104 - openUpwards 105 - style={styles.button} 106 - hitSlop={LANG_DROPDOWN_HITSLOP} 107 - accessibilityLabel={_(msg`Language selection`)} 108 - accessibilityHint=""> 109 - {postLanguagesPref.length > 0 ? ( 110 - <Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}> 111 - {postLanguagesPref 112 - .map(lang => codeToLanguageName(lang, langPrefs.appLanguage)) 113 - .join(', ')} 114 - </Text> 115 - ) : ( 116 - <FontAwesomeIcon 117 - icon="language" 118 - style={pal.link as FontAwesomeIconStyle} 119 - size={26} 120 - /> 121 - )} 122 - </DropdownButton> 123 - ) 124 - } 125 - 126 - const styles = StyleSheet.create({ 127 - button: { 128 - marginHorizontal: 15, 129 - }, 130 - label: { 131 - maxWidth: 100, 132 - }, 133 - })
+382
src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx
··· 1 + import {useCallback, useMemo, useState} from 'react' 2 + import {Keyboard, useWindowDimensions, View} from 'react-native' 3 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants' 8 + import {languageName} from '#/locale/helpers' 9 + import {codeToLanguageName} from '#/locale/helpers' 10 + import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages' 11 + import {isNative, isWeb} from '#/platform/detection' 12 + import { 13 + toPostLanguages, 14 + useLanguagePrefs, 15 + useLanguagePrefsApi, 16 + } from '#/state/preferences/languages' 17 + import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 18 + import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 19 + import {atoms as a, useTheme, web} from '#/alf' 20 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 21 + import * as Dialog from '#/components/Dialog' 22 + import {SearchInput} from '#/components/forms/SearchInput' 23 + import * as Toggle from '#/components/forms/Toggle' 24 + import {Globe_Stroke2_Corner0_Rounded as GlobeIcon} from '#/components/icons/Globe' 25 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 26 + import {Text} from '#/components/Typography' 27 + 28 + export function SelectPostLanguagesBtn() { 29 + const {_} = useLingui() 30 + const langPrefs = useLanguagePrefs() 31 + const t = useTheme() 32 + const control = Dialog.useDialogControl() 33 + 34 + const onPressMore = useCallback(async () => { 35 + if (isNative) { 36 + if (Keyboard.isVisible()) { 37 + Keyboard.dismiss() 38 + } 39 + } 40 + control.open() 41 + }, [control]) 42 + 43 + const postLanguagesPref = toPostLanguages(langPrefs.postLanguage) 44 + 45 + return ( 46 + <> 47 + <Button 48 + testID="selectLangBtn" 49 + onPress={onPressMore} 50 + size="small" 51 + hitSlop={LANG_DROPDOWN_HITSLOP} 52 + label={_( 53 + msg({ 54 + message: `Post language selection`, 55 + comment: `Accessibility label for button that opens dialog to choose post language settings`, 56 + }), 57 + )} 58 + accessibilityHint={_(msg`Opens post language settings`)} 59 + style={[a.mx_md]}> 60 + {({pressed, hovered, focused}) => { 61 + const color = 62 + pressed || hovered || focused 63 + ? t.palette.primary_300 64 + : t.palette.primary_500 65 + if (postLanguagesPref.length > 0) { 66 + return ( 67 + <Text 68 + style={[ 69 + {color}, 70 + a.font_bold, 71 + a.text_sm, 72 + a.leading_snug, 73 + {maxWidth: 100}, 74 + ]} 75 + numberOfLines={1}> 76 + {postLanguagesPref 77 + .map(lang => codeToLanguageName(lang, langPrefs.appLanguage)) 78 + .join(', ')} 79 + </Text> 80 + ) 81 + } else { 82 + return <GlobeIcon size="xs" style={{color}} /> 83 + } 84 + }} 85 + </Button> 86 + 87 + <LanguageDialog control={control} /> 88 + </> 89 + ) 90 + } 91 + 92 + function LanguageDialog({control}: {control: Dialog.DialogControlProps}) { 93 + const {height} = useWindowDimensions() 94 + const insets = useSafeAreaInsets() 95 + 96 + const renderErrorBoundary = useCallback( 97 + (error: any) => <DialogError details={String(error)} />, 98 + [], 99 + ) 100 + 101 + return ( 102 + <Dialog.Outer 103 + control={control} 104 + nativeOptions={{minHeight: height - insets.top}}> 105 + <Dialog.Handle /> 106 + <ErrorBoundary renderError={renderErrorBoundary}> 107 + <PostLanguagesSettingsDialogInner /> 108 + </ErrorBoundary> 109 + </Dialog.Outer> 110 + ) 111 + } 112 + 113 + export function PostLanguagesSettingsDialogInner() { 114 + const control = Dialog.useDialogContext() 115 + const [headerHeight, setHeaderHeight] = useState(0) 116 + 117 + const allowedLanguages = useMemo(() => { 118 + const uniqueLanguagesMap = LANGUAGES.filter(lang => !!lang.code2).reduce( 119 + (acc, lang) => { 120 + acc[lang.code2] = lang 121 + return acc 122 + }, 123 + {} as Record<string, Language>, 124 + ) 125 + 126 + return Object.values(uniqueLanguagesMap) 127 + }, []) 128 + 129 + const langPrefs = useLanguagePrefs() 130 + const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>( 131 + langPrefs.postLanguage.split(',') || [langPrefs.primaryLanguage], 132 + ) 133 + const [search, setSearch] = useState('') 134 + 135 + const setLangPrefs = useLanguagePrefsApi() 136 + const t = useTheme() 137 + const {_} = useLingui() 138 + 139 + const handleClose = () => { 140 + control.close(() => { 141 + let langsString = checkedLanguagesCode2.join(',') 142 + if (!langsString) { 143 + langsString = langPrefs.primaryLanguage 144 + } 145 + setLangPrefs.setPostLanguage(langsString) 146 + }) 147 + } 148 + 149 + // NOTE(@elijaharita): Displayed languages are split into 3 lists for 150 + // ordering. 151 + const displayedLanguages = useMemo(() => { 152 + function mapCode2List(code2List: string[]) { 153 + return code2List.map(code2 => LANGUAGES_MAP_CODE2[code2]).filter(Boolean) 154 + } 155 + 156 + // NOTE(@elijaharita): Get recent language codes and map them to language 157 + // objects. Both the user account's saved language history and the current 158 + // checked languages are displayed here. 159 + const recentLanguagesCode2 = 160 + Array.from( 161 + new Set([...checkedLanguagesCode2, ...langPrefs.postLanguageHistory]), 162 + ).slice(0, 5) || [] 163 + const recentLanguages = mapCode2List(recentLanguagesCode2) 164 + 165 + // NOTE(@elijaharita): helper functions 166 + const matchesSearch = (lang: Language) => 167 + lang.name.toLowerCase().includes(search.toLowerCase()) 168 + const isChecked = (lang: Language) => 169 + checkedLanguagesCode2.includes(lang.code2) 170 + const isInRecents = (lang: Language) => 171 + recentLanguagesCode2.includes(lang.code2) 172 + 173 + const checkedRecent = recentLanguages.filter(isChecked) 174 + 175 + if (search) { 176 + // NOTE(@elijaharita): if a search is active, we ALWAYS show checked 177 + // items, as well as any items that match the search. 178 + const uncheckedRecent = recentLanguages 179 + .filter(lang => !isChecked(lang)) 180 + .filter(matchesSearch) 181 + const unchecked = allowedLanguages.filter(lang => !isChecked(lang)) 182 + const all = unchecked 183 + .filter(matchesSearch) 184 + .filter(lang => !isInRecents(lang)) 185 + 186 + return { 187 + all, 188 + checkedRecent, 189 + uncheckedRecent, 190 + } 191 + } else { 192 + // NOTE(@elijaharita): if no search is active, we show everything. 193 + const uncheckedRecent = recentLanguages.filter(lang => !isChecked(lang)) 194 + const all = allowedLanguages 195 + .filter(lang => !recentLanguagesCode2.includes(lang.code2)) 196 + .filter(lang => !isInRecents(lang)) 197 + 198 + return { 199 + all, 200 + checkedRecent, 201 + uncheckedRecent, 202 + } 203 + } 204 + }, [ 205 + allowedLanguages, 206 + search, 207 + langPrefs.postLanguageHistory, 208 + checkedLanguagesCode2, 209 + ]) 210 + 211 + const listHeader = ( 212 + <View 213 + style={[a.pb_xs, t.atoms.bg, isNative && a.pt_2xl]} 214 + onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}> 215 + <View style={[a.flex_row, a.w_full, a.justify_between]}> 216 + <View> 217 + <Text 218 + nativeID="dialog-title" 219 + style={[ 220 + t.atoms.text, 221 + a.text_left, 222 + a.font_bold, 223 + a.text_xl, 224 + a.mb_sm, 225 + ]}> 226 + <Trans>Choose Post Languages</Trans> 227 + </Text> 228 + <Text 229 + nativeID="dialog-description" 230 + style={[ 231 + t.atoms.text_contrast_medium, 232 + a.text_left, 233 + a.text_md, 234 + a.mb_lg, 235 + ]}> 236 + <Trans>Select up to 3 languages used in this post</Trans> 237 + </Text> 238 + </View> 239 + 240 + {isWeb && ( 241 + <Button 242 + variant="ghost" 243 + size="small" 244 + color="secondary" 245 + shape="round" 246 + label={_(msg`Close dialog`)} 247 + onPress={handleClose}> 248 + <ButtonIcon icon={XIcon} /> 249 + </Button> 250 + )} 251 + </View> 252 + 253 + <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs, a.pb_0]}> 254 + <SearchInput 255 + value={search} 256 + onChangeText={setSearch} 257 + placeholder={_(msg`Search languages`)} 258 + label={_(msg`Search languages`)} 259 + maxLength={50} 260 + onClearText={() => setSearch('')} 261 + /> 262 + </View> 263 + </View> 264 + ) 265 + 266 + const isCheckedRecentEmpty = 267 + displayedLanguages.checkedRecent.length > 0 || 268 + displayedLanguages.uncheckedRecent.length > 0 269 + 270 + const isDisplayedLanguagesEmpty = displayedLanguages.all.length === 0 271 + 272 + const flatListData = [ 273 + ...(isCheckedRecentEmpty 274 + ? [{type: 'header', label: _(msg`Recently used`)}] 275 + : []), 276 + ...displayedLanguages.checkedRecent.map(lang => ({type: 'item', lang})), 277 + ...displayedLanguages.uncheckedRecent.map(lang => ({type: 'item', lang})), 278 + ...(isDisplayedLanguagesEmpty 279 + ? [] 280 + : [{type: 'header', label: _(msg`All languages`)}]), 281 + ...displayedLanguages.all.map(lang => ({type: 'item', lang})), 282 + ] 283 + 284 + return ( 285 + <Toggle.Group 286 + values={checkedLanguagesCode2} 287 + onChange={setCheckedLanguagesCode2} 288 + type="checkbox" 289 + maxSelections={3} 290 + label={_(msg`Select languages`)} 291 + style={web([a.contents])}> 292 + <Dialog.InnerFlatList 293 + data={flatListData} 294 + ListHeaderComponent={listHeader} 295 + stickyHeaderIndices={[0]} 296 + contentContainerStyle={[a.gap_0]} 297 + style={[isNative && a.px_lg, web({paddingBottom: 120})]} 298 + scrollIndicatorInsets={{top: headerHeight}} 299 + renderItem={({item, index}) => { 300 + if (item.type === 'header') { 301 + return ( 302 + <Text 303 + key={index} 304 + style={[ 305 + a.px_0, 306 + a.py_md, 307 + a.font_bold, 308 + a.text_xs, 309 + t.atoms.text_contrast_low, 310 + a.pt_3xl, 311 + ]}> 312 + {item.label} 313 + </Text> 314 + ) 315 + } 316 + const lang = item.lang 317 + 318 + return ( 319 + <Toggle.Item 320 + key={lang.code2} 321 + name={lang.code2} 322 + label={languageName(lang, langPrefs.appLanguage)} 323 + style={[ 324 + t.atoms.border_contrast_low, 325 + a.border_b, 326 + a.rounded_0, 327 + a.px_0, 328 + a.py_md, 329 + ]}> 330 + <Toggle.LabelText style={[a.flex_1]}> 331 + {languageName(lang, langPrefs.appLanguage)} 332 + </Toggle.LabelText> 333 + <Toggle.Checkbox /> 334 + </Toggle.Item> 335 + ) 336 + }} 337 + footer={ 338 + <Dialog.FlatListFooter> 339 + <Button 340 + label={_(msg`Close dialog`)} 341 + onPress={handleClose} 342 + color="primary" 343 + size="large"> 344 + <ButtonText> 345 + <Trans>Done</Trans> 346 + </ButtonText> 347 + </Button> 348 + </Dialog.FlatListFooter> 349 + } 350 + /> 351 + </Toggle.Group> 352 + ) 353 + } 354 + 355 + function DialogError({details}: {details?: string}) { 356 + const {_} = useLingui() 357 + const control = Dialog.useDialogContext() 358 + 359 + return ( 360 + <Dialog.ScrollableInner 361 + style={a.gap_md} 362 + label={_(msg`An error has occurred`)}> 363 + <Dialog.Close /> 364 + <ErrorScreen 365 + title={_(msg`Oh no!`)} 366 + message={_( 367 + msg`There was an unexpected issue in the application. Please let us know if this happened to you!`, 368 + )} 369 + details={details} 370 + /> 371 + <Button 372 + label={_(msg`Close dialog`)} 373 + onPress={() => control.close()} 374 + color="primary" 375 + size="large"> 376 + <ButtonText> 377 + <Trans>Close</Trans> 378 + </ButtonText> 379 + </Button> 380 + </Dialog.ScrollableInner> 381 + ) 382 + }
+11 -4
src/view/com/feeds/FeedPage.tsx
··· 6 6 import {type NavigationProp, useNavigation} from '@react-navigation/native' 7 7 import {useQueryClient} from '@tanstack/react-query' 8 8 9 - import {VIDEO_FEED_URIS} from '#/lib/constants' 9 + import {DISCOVER_FEED_URI, VIDEO_FEED_URIS} from '#/lib/constants' 10 10 import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 11 11 import {ComposeIcon2} from '#/lib/icons' 12 12 import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers' ··· 18 18 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 19 19 import {useSetHomeBadge} from '#/state/home-badge' 20 20 import {type FeedSourceInfo} from '#/state/queries/feed' 21 - import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 22 - import {type FeedDescriptor, type FeedParams} from '#/state/queries/post-feed' 21 + import { 22 + type FeedDescriptor, 23 + type FeedParams, 24 + RQKEY as FEED_RQKEY, 25 + } from '#/state/queries/post-feed' 23 26 import {truncateAndInvalidate} from '#/state/queries/util' 24 27 import {useSession} from '#/state/session' 25 28 import {useSetMinimalShellMode} from '#/state/shell' ··· 127 130 }, [scrollToTop, feed, queryClient]) 128 131 129 132 const shouldPrefetch = isNative && isPageAdjacent 133 + const isDiscoverFeed = feedInfo.uri === DISCOVER_FEED_URI 130 134 return ( 131 - <View testID={testID}> 135 + <View 136 + testID={testID} 137 + // @ts-expect-error web only -sfn 138 + dataSet={{nosnippet: isDiscoverFeed ? '' : undefined}}> 132 139 <MainScrollProvider> 133 140 <FeedFeedbackProvider value={feedFeedback}> 134 141 <PostFeed
-4
src/view/com/modals/Modal.tsx
··· 11 11 import * as DeleteAccountModal from './DeleteAccount' 12 12 import * as InviteCodesModal from './InviteCodes' 13 13 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 14 - import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 15 14 import * as UserAddRemoveListsModal from './UserAddRemoveLists' 16 15 17 16 const DEFAULT_SNAPPOINTS = ['90%'] ··· 60 59 } else if (activeModal?.name === 'content-languages-settings') { 61 60 snapPoints = ContentLanguagesSettingsModal.snapPoints 62 61 element = <ContentLanguagesSettingsModal.Component /> 63 - } else if (activeModal?.name === 'post-languages-settings') { 64 - snapPoints = PostLanguagesSettingsModal.snapPoints 65 - element = <PostLanguagesSettingsModal.Component /> 66 62 } else { 67 63 return null 68 64 }
-3
src/view/com/modals/Modal.web.tsx
··· 10 10 import * as DeleteAccountModal from './DeleteAccount' 11 11 import * as InviteCodesModal from './InviteCodes' 12 12 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 13 - import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' 14 13 import * as UserAddRemoveLists from './UserAddRemoveLists' 15 14 16 15 export function ModalsContainer() { ··· 59 58 element = <InviteCodesModal.Component /> 60 59 } else if (modal.name === 'content-languages-settings') { 61 60 element = <ContentLanguagesSettingsModal.Component /> 62 - } else if (modal.name === 'post-languages-settings') { 63 - element = <PostLanguagesSettingsModal.Component /> 64 61 } else { 65 62 return null 66 63 }
-145
src/view/com/modals/lang-settings/PostLanguagesSettings.tsx
··· 1 - import React from 'react' 2 - import {StyleSheet, View} from 'react-native' 3 - import {Trans} from '@lingui/macro' 4 - 5 - import {usePalette} from '#/lib/hooks/usePalette' 6 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 7 - import {deviceLanguageCodes} from '#/locale/deviceLocales' 8 - import {languageName} from '#/locale/helpers' 9 - import {useModalControls} from '#/state/modals' 10 - import { 11 - hasPostLanguage, 12 - useLanguagePrefs, 13 - useLanguagePrefsApi, 14 - } from '#/state/preferences/languages' 15 - import {ToggleButton} from '#/view/com/util/forms/ToggleButton' 16 - import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' 17 - import {Text} from '../../util/text/Text' 18 - import {ScrollView} from '../util' 19 - import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' 20 - 21 - export const snapPoints = ['100%'] 22 - 23 - export function Component() { 24 - const {closeModal} = useModalControls() 25 - const langPrefs = useLanguagePrefs() 26 - const setLangPrefs = useLanguagePrefsApi() 27 - const pal = usePalette('default') 28 - const {isMobile} = useWebMediaQueries() 29 - const onPressDone = React.useCallback(() => { 30 - closeModal() 31 - }, [closeModal]) 32 - 33 - const languages = React.useMemo(() => { 34 - const langs = LANGUAGES.filter( 35 - lang => 36 - !!lang.code2.trim() && 37 - LANGUAGES_MAP_CODE2[lang.code2].code3 === lang.code3, 38 - ) 39 - // sort so that device & selected languages are on top, then alphabetically 40 - langs.sort((a, b) => { 41 - const hasA = 42 - hasPostLanguage(langPrefs.postLanguage, a.code2) || 43 - deviceLanguageCodes.includes(a.code2) 44 - const hasB = 45 - hasPostLanguage(langPrefs.postLanguage, b.code2) || 46 - deviceLanguageCodes.includes(b.code2) 47 - if (hasA === hasB) return a.name.localeCompare(b.name) 48 - if (hasA) return -1 49 - return 1 50 - }) 51 - return langs 52 - }, [langPrefs]) 53 - 54 - const onPress = React.useCallback( 55 - (code2: string) => { 56 - setLangPrefs.togglePostLanguage(code2) 57 - }, 58 - [setLangPrefs], 59 - ) 60 - 61 - return ( 62 - <View 63 - testID="postLanguagesModal" 64 - style={[ 65 - pal.view, 66 - styles.container, 67 - // @ts-ignore vh is on web only 68 - isMobile 69 - ? { 70 - paddingTop: 20, 71 - } 72 - : { 73 - maxHeight: '90vh', 74 - }, 75 - ]}> 76 - <Text style={[pal.text, styles.title]}> 77 - <Trans>Post Languages</Trans> 78 - </Text> 79 - <Text style={[pal.text, styles.description]}> 80 - <Trans>Which languages are used in this post?</Trans> 81 - </Text> 82 - <ScrollView style={styles.scrollContainer}> 83 - {languages.map(lang => { 84 - const isSelected = hasPostLanguage(langPrefs.postLanguage, lang.code2) 85 - 86 - // enforce a max of 3 selections for post languages 87 - let isDisabled = false 88 - if (langPrefs.postLanguage.split(',').length >= 3 && !isSelected) { 89 - isDisabled = true 90 - } 91 - 92 - return ( 93 - <ToggleButton 94 - key={lang.code2} 95 - label={languageName(lang, langPrefs.appLanguage)} 96 - isSelected={isSelected} 97 - onPress={() => (isDisabled ? undefined : onPress(lang.code2))} 98 - style={[ 99 - pal.border, 100 - styles.languageToggle, 101 - isDisabled && styles.dimmed, 102 - ]} 103 - /> 104 - ) 105 - })} 106 - <View 107 - style={{ 108 - height: isMobile ? 60 : 0, 109 - }} 110 - /> 111 - </ScrollView> 112 - <ConfirmLanguagesButton onPress={onPressDone} /> 113 - </View> 114 - ) 115 - } 116 - 117 - const styles = StyleSheet.create({ 118 - container: { 119 - flex: 1, 120 - }, 121 - title: { 122 - textAlign: 'center', 123 - fontWeight: '600', 124 - fontSize: 24, 125 - marginBottom: 12, 126 - }, 127 - description: { 128 - textAlign: 'center', 129 - paddingHorizontal: 16, 130 - marginBottom: 10, 131 - }, 132 - scrollContainer: { 133 - flex: 1, 134 - paddingHorizontal: 10, 135 - }, 136 - languageToggle: { 137 - borderTopWidth: 1, 138 - borderRadius: 0, 139 - paddingHorizontal: 6, 140 - paddingVertical: 12, 141 - }, 142 - dimmed: { 143 - opacity: 0.5, 144 - }, 145 - })
-397
src/view/com/util/forms/DropdownButton.tsx
··· 1 - import {type PropsWithChildren} from 'react' 2 - import {useMemo, useRef} from 'react' 3 - import { 4 - Dimensions, 5 - type GestureResponderEvent, 6 - type Insets, 7 - type StyleProp, 8 - StyleSheet, 9 - TouchableOpacity, 10 - TouchableWithoutFeedback, 11 - useWindowDimensions, 12 - View, 13 - type ViewStyle, 14 - } from 'react-native' 15 - import Animated, {FadeIn, FadeInDown, FadeInUp} from 'react-native-reanimated' 16 - import RootSiblings from 'react-native-root-siblings' 17 - import {type IconProp} from '@fortawesome/fontawesome-svg-core' 18 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 19 - import {msg} from '@lingui/macro' 20 - import {useLingui} from '@lingui/react' 21 - import type React from 'react' 22 - 23 - import {HITSLOP_10} from '#/lib/constants' 24 - import {usePalette} from '#/lib/hooks/usePalette' 25 - import {colors} from '#/lib/styles' 26 - import {useTheme} from '#/lib/ThemeContext' 27 - import {isWeb} from '#/platform/detection' 28 - import {native} from '#/alf' 29 - import {FullWindowOverlay} from '#/components/FullWindowOverlay' 30 - import {Text} from '../text/Text' 31 - import {Button, type ButtonType} from './Button' 32 - 33 - const ESTIMATED_BTN_HEIGHT = 50 34 - const ESTIMATED_SEP_HEIGHT = 16 35 - const ESTIMATED_HEADING_HEIGHT = 60 36 - 37 - export interface DropdownItemButton { 38 - testID?: string 39 - icon?: IconProp 40 - label: string 41 - onPress: () => void 42 - } 43 - export interface DropdownItemSeparator { 44 - sep: true 45 - } 46 - export interface DropdownItemHeading { 47 - heading: true 48 - label: string 49 - } 50 - export type DropdownItem = 51 - | DropdownItemButton 52 - | DropdownItemSeparator 53 - | DropdownItemHeading 54 - type MaybeDropdownItem = DropdownItem | false | undefined 55 - 56 - export type DropdownButtonType = ButtonType | 'bare' 57 - 58 - interface DropdownButtonProps { 59 - testID?: string 60 - type?: DropdownButtonType 61 - style?: StyleProp<ViewStyle> 62 - items: MaybeDropdownItem[] 63 - label?: string 64 - menuWidth?: number 65 - children?: React.ReactNode 66 - openToRight?: boolean 67 - openUpwards?: boolean 68 - rightOffset?: number 69 - bottomOffset?: number 70 - hitSlop?: Insets 71 - accessibilityLabel?: string 72 - accessibilityHint?: string 73 - } 74 - 75 - /** 76 - * @deprecated use Menu from `#/components/Menu.tsx` instead 77 - */ 78 - export function DropdownButton({ 79 - testID, 80 - type = 'bare', 81 - style, 82 - items, 83 - label, 84 - menuWidth, 85 - children, 86 - openToRight = false, 87 - openUpwards = false, 88 - rightOffset = 0, 89 - bottomOffset = 0, 90 - hitSlop = HITSLOP_10, 91 - accessibilityLabel, 92 - }: PropsWithChildren<DropdownButtonProps>) { 93 - const {_} = useLingui() 94 - 95 - const ref1 = useRef<View>(null) 96 - const ref2 = useRef<View>(null) 97 - 98 - const onPress = (e: GestureResponderEvent) => { 99 - const ref = ref1.current || ref2.current 100 - const {height: winHeight} = Dimensions.get('window') 101 - const pressY = e.nativeEvent.pageY 102 - ref?.measure( 103 - ( 104 - _x: number, 105 - _y: number, 106 - width: number, 107 - _height: number, 108 - pageX: number, 109 - pageY: number, 110 - ) => { 111 - if (!menuWidth) { 112 - menuWidth = 200 113 - } 114 - let estimatedMenuHeight = 0 115 - for (const item of items) { 116 - if (item && isSep(item)) { 117 - estimatedMenuHeight += ESTIMATED_SEP_HEIGHT 118 - } else if (item && isBtn(item)) { 119 - estimatedMenuHeight += ESTIMATED_BTN_HEIGHT 120 - } else if (item && isHeading(item)) { 121 - estimatedMenuHeight += ESTIMATED_HEADING_HEIGHT 122 - } 123 - } 124 - const newX = openToRight 125 - ? pageX + width + rightOffset 126 - : pageX + width - menuWidth 127 - 128 - // Add a bit of additional room 129 - let newY = pressY + bottomOffset + 20 130 - if (openUpwards || newY + estimatedMenuHeight > winHeight) { 131 - newY -= estimatedMenuHeight 132 - } 133 - createDropdownMenu( 134 - newX, 135 - newY, 136 - pageY, 137 - menuWidth, 138 - items.filter(v => !!v) as DropdownItem[], 139 - openUpwards, 140 - ) 141 - }, 142 - ) 143 - } 144 - 145 - const numItems = useMemo( 146 - () => 147 - items.filter(item => { 148 - if (item === undefined || item === false) { 149 - return false 150 - } 151 - 152 - return isBtn(item) 153 - }).length, 154 - [items], 155 - ) 156 - 157 - if (type === 'bare') { 158 - return ( 159 - <TouchableOpacity 160 - testID={testID} 161 - style={style} 162 - onPress={onPress} 163 - hitSlop={hitSlop} 164 - ref={ref1} 165 - accessibilityRole="button" 166 - accessibilityLabel={ 167 - accessibilityLabel || _(msg`Opens ${numItems} options`) 168 - } 169 - accessibilityHint=""> 170 - {children} 171 - </TouchableOpacity> 172 - ) 173 - } 174 - return ( 175 - <View ref={ref2}> 176 - <Button 177 - type={type} 178 - testID={testID} 179 - onPress={onPress} 180 - style={style} 181 - label={label}> 182 - {children} 183 - </Button> 184 - </View> 185 - ) 186 - } 187 - 188 - function createDropdownMenu( 189 - x: number, 190 - y: number, 191 - pageY: number, 192 - width: number, 193 - items: DropdownItem[], 194 - opensUpwards = false, 195 - ): RootSiblings { 196 - const onPressItem = (index: number) => { 197 - sibling.destroy() 198 - const item = items[index] 199 - if (isBtn(item)) { 200 - item.onPress() 201 - } 202 - } 203 - const onOuterPress = () => sibling.destroy() 204 - const sibling = new RootSiblings( 205 - ( 206 - <DropdownItems 207 - onOuterPress={onOuterPress} 208 - x={x} 209 - y={y} 210 - pageY={pageY} 211 - width={width} 212 - items={items} 213 - onPressItem={onPressItem} 214 - openUpwards={opensUpwards} 215 - /> 216 - ), 217 - ) 218 - return sibling 219 - } 220 - 221 - type DropDownItemProps = { 222 - onOuterPress: () => void 223 - x: number 224 - y: number 225 - pageY: number 226 - width: number 227 - items: DropdownItem[] 228 - onPressItem: (index: number) => void 229 - openUpwards: boolean 230 - } 231 - 232 - const DropdownItems = ({ 233 - onOuterPress, 234 - x, 235 - y, 236 - pageY, 237 - width, 238 - items, 239 - onPressItem, 240 - openUpwards, 241 - }: DropDownItemProps) => { 242 - const pal = usePalette('default') 243 - const theme = useTheme() 244 - const {_} = useLingui() 245 - const {height: screenHeight} = useWindowDimensions() 246 - const dropDownBackgroundColor = 247 - theme.colorScheme === 'dark' ? pal.btn : pal.view 248 - const separatorColor = 249 - theme.colorScheme === 'dark' ? pal.borderDark : pal.border 250 - 251 - const numItems = items.filter(isBtn).length 252 - 253 - // TODO: Refactor dropdown components to: 254 - // - (On web, if not handled by React Native) use semantic <select /> 255 - // and <option /> elements for keyboard navigation out of the box 256 - // - (On mobile) be buttons by default, accept `label` and `nativeID` 257 - // props, and always have an explicit label 258 - return ( 259 - <FullWindowOverlay> 260 - {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */} 261 - <TouchableWithoutFeedback 262 - onPress={onOuterPress} 263 - accessibilityLabel={_(msg`Toggle dropdown`)} 264 - accessibilityHint=""> 265 - <Animated.View 266 - entering={FadeIn} 267 - style={[ 268 - styles.bg, 269 - // On web we need to adjust the top and bottom relative to the scroll position 270 - isWeb 271 - ? { 272 - top: -pageY, 273 - bottom: pageY - screenHeight, 274 - } 275 - : { 276 - top: 0, 277 - bottom: 0, 278 - }, 279 - ]} 280 - /> 281 - </TouchableWithoutFeedback> 282 - <Animated.View 283 - entering={native( 284 - openUpwards ? FadeInDown.springify(1000) : FadeInUp.springify(1000), 285 - )} 286 - style={[ 287 - styles.menu, 288 - {left: x, top: y, width}, 289 - dropDownBackgroundColor, 290 - ]}> 291 - {items.map((item, index) => { 292 - if (isBtn(item)) { 293 - return ( 294 - <TouchableOpacity 295 - testID={item.testID} 296 - key={index} 297 - style={[styles.menuItem]} 298 - onPress={() => onPressItem(index)} 299 - accessibilityRole="button" 300 - accessibilityLabel={item.label} 301 - accessibilityHint={_( 302 - msg`Selects option ${index + 1} of ${numItems}`, 303 - )}> 304 - {item.icon && ( 305 - <FontAwesomeIcon 306 - style={styles.icon} 307 - icon={item.icon} 308 - color={pal.text.color as string} 309 - /> 310 - )} 311 - <Text style={[styles.label, pal.text]}>{item.label}</Text> 312 - </TouchableOpacity> 313 - ) 314 - } else if (isSep(item)) { 315 - return ( 316 - <View key={index} style={[styles.separator, separatorColor]} /> 317 - ) 318 - } else if (isHeading(item)) { 319 - return ( 320 - <View style={[styles.heading, pal.border]} key={index}> 321 - <Text style={[pal.text, styles.headingLabel]}> 322 - {item.label} 323 - </Text> 324 - </View> 325 - ) 326 - } 327 - return null 328 - })} 329 - </Animated.View> 330 - </FullWindowOverlay> 331 - ) 332 - } 333 - 334 - function isSep(item: DropdownItem): item is DropdownItemSeparator { 335 - return 'sep' in item && item.sep 336 - } 337 - function isHeading(item: DropdownItem): item is DropdownItemHeading { 338 - return 'heading' in item && item.heading 339 - } 340 - function isBtn(item: DropdownItem): item is DropdownItemButton { 341 - return !isSep(item) && !isHeading(item) 342 - } 343 - 344 - const styles = StyleSheet.create({ 345 - bg: { 346 - position: 'absolute', 347 - left: 0, 348 - width: '100%', 349 - backgroundColor: 'rgba(0, 0, 0, 0.1)', 350 - }, 351 - menu: { 352 - position: 'absolute', 353 - backgroundColor: '#fff', 354 - borderRadius: 14, 355 - paddingVertical: 6, 356 - }, 357 - menuItem: { 358 - flexDirection: 'row', 359 - alignItems: 'center', 360 - paddingVertical: 10, 361 - paddingLeft: 15, 362 - paddingRight: 40, 363 - }, 364 - menuItemBorder: { 365 - borderTopWidth: 1, 366 - borderTopColor: colors.gray1, 367 - marginTop: 4, 368 - paddingTop: 12, 369 - }, 370 - icon: { 371 - marginLeft: 2, 372 - marginRight: 8, 373 - flexShrink: 0, 374 - }, 375 - label: { 376 - fontSize: 18, 377 - flexShrink: 1, 378 - flexGrow: 1, 379 - }, 380 - separator: { 381 - borderTopWidth: 1, 382 - marginVertical: 8, 383 - }, 384 - heading: { 385 - flexDirection: 'row', 386 - justifyContent: 'center', 387 - paddingVertical: 10, 388 - paddingLeft: 15, 389 - paddingRight: 20, 390 - borderBottomWidth: 1, 391 - marginBottom: 6, 392 - }, 393 - headingLabel: { 394 - fontSize: 18, 395 - fontWeight: '600', 396 - }, 397 - })
+5 -46
src/view/screens/Debug.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import {usePalette} from '#/lib/hooks/usePalette' 7 - import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 7 + import { 8 + type CommonNavigatorParams, 9 + type NativeStackScreenProps, 10 + } from '#/lib/routes/types' 8 11 import {s} from '#/lib/styles' 9 - import {PaletteColorName, ThemeProvider} from '#/lib/ThemeContext' 12 + import {type PaletteColorName, ThemeProvider} from '#/lib/ThemeContext' 10 13 import {EmptyState} from '#/view/com/util/EmptyState' 11 14 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 12 15 import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 13 16 import {Button} from '#/view/com/util/forms/Button' 14 - import { 15 - DropdownButton, 16 - DropdownItem, 17 - } from '#/view/com/util/forms/DropdownButton' 18 17 import {ToggleButton} from '#/view/com/util/forms/ToggleButton' 19 18 import * as LoadingPlaceholder from '#/view/com/util/LoadingPlaceholder' 20 19 import {Text} from '#/view/com/util/text/Text' ··· 134 133 <ScrollView style={[s.pl10, s.pr10]}> 135 134 <Heading label="Buttons" /> 136 135 <ButtonsView /> 137 - <Heading label="Dropdown Buttons" /> 138 - <DropdownButtonsView /> 139 136 <Heading label="Toggle Buttons" /> 140 137 <ToggleButtonsView /> 141 138 <View style={s.footerSpacer} /> ··· 391 388 label="Default light" 392 389 style={buttonStyles} 393 390 /> 394 - </View> 395 - </View> 396 - ) 397 - } 398 - 399 - const DROPDOWN_ITEMS: DropdownItem[] = [ 400 - { 401 - icon: ['far', 'paste'], 402 - label: 'Copy post text', 403 - onPress() {}, 404 - }, 405 - { 406 - icon: 'share', 407 - label: 'Share...', 408 - onPress() {}, 409 - }, 410 - { 411 - icon: 'circle-exclamation', 412 - label: 'Report post', 413 - onPress() {}, 414 - }, 415 - ] 416 - function DropdownButtonsView() { 417 - const defaultPal = usePalette('default') 418 - return ( 419 - <View style={[defaultPal.view]}> 420 - <View style={s.mb5}> 421 - <DropdownButton 422 - type="primary" 423 - items={DROPDOWN_ITEMS} 424 - menuWidth={200} 425 - label="Primary button" 426 - /> 427 - </View> 428 - <View style={s.mb5}> 429 - <DropdownButton type="bare" items={DROPDOWN_ITEMS} menuWidth={200}> 430 - <Text>Bare</Text> 431 - </DropdownButton> 432 391 </View> 433 392 </View> 434 393 )