Bluesky app fork with some witchin' additions 💫

New Onboarding (#2596)

* Add round and square buttons

* Allow some style for buttons, add icons

* Change text selection color

* Center button text, whoops

* Outer layout, some primitive updates

* WIP

* onboarding feed prefs (#2590)

* add `style` to toggle label to modify text style

* Revert "add `style` to toggle label to modify text style"

This reverts commit 8f4b517b8585ca64a4bf44f6cb40ac070ece8932.

* following feed prefs

* remove unnecessary memo

* reusable divider component

* org imports

* add finished screen

* Theme SelectedAccountCard

* Require at least 3 interests

* Placeholder save logic

* WIP algo feeds

* Improve lineHeight handling, add RichText, improve Link by adding InlineLink

* Inherit lineHeight in heading comps

* Algo feeds mostly good

* Topical feeds ish

* Layout cleanup

* Improve button styles

* moderation prefs for onboarding (#2594)

* WIP algo feeds

* modify controlalbelgroup typing for easy .map()

* adjust padding on button

* add moderation screen

* add moderation screen

* add moderation screen

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Fix toggle button styles

* A11y props on outer portal

* Put it all on red

* New data shape

* Handle mock data

* Bulk write (not yet)

* Remove interests validation

* Clean up interests

* i18n layout and first step

* Clean up suggested follows screen

* Clean up following step

* Clean up algo feeds step

* Clean up topical feeds

* Add skeleton for feed card

* WIP moderation step

* cleanup moderation styles (#2605)

* cleanup moderation styles

* fix(?) toggle button group styles

* adjust toggle to fit any screen

* Some more cleanup

* Icons

* ToggleButton tweaks

* Reset

* Hook up data

* Better suggestions

* Bulk write

* Some logging

* Use new api

* Concat topical feeds

* Metrics

* Disable links in RichText, feedcards

* Tweak primary feed cards

* Update metrics

* Fix layout shift

* Fix ToggleButton again, whoops

* Error state

* Bump api package, ensure interests are saved

* Better fix for autofill

* i18n, button positions

* Remove unused export

* Add default prefs object

* Fix overflow in user cards

* Add 2 lines of bios to suggested accounts cards

* Nits

* Don't resolve facets by default

* Update storybook

* Disable flag for now

* Remove age dialog from moderations step

* Improvements and tweaks to new onboarding

---------

Co-authored-by: Hailey <153161762+haileyok@users.noreply.github.com>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>

authored by

Eric Bailey
Eric Bailey
Hailey
Paul Frazee
and committed by
GitHub
3371038f 54435035

+3513 -209
+1
assets/icons/arrowRotateCounterClockwise_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 3a1 1 0 0 1 1 1v1.423c.498-.46 1.02-.869 1.58-1.213C8.863 3.423 10.302 3 12.028 3a9 9 0 1 1-8.487 12 1 1 0 0 1 1.885-.667A7 7 0 1 0 12.028 5c-1.37 0-2.444.327-3.402.915-.474.29-.93.652-1.383 1.085H9a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
+1
assets/icons/at_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z" clip-rule="evenodd"/></svg>
+1
assets/icons/check_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z" clip-rule="evenodd"/></svg>
+1
assets/icons/chevronLeft_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M15.707 3.293a1 1 0 0 1 0 1.414L8.414 12l7.293 7.293a1 1 0 0 1-1.414 1.414l-8-8a1 1 0 0 1 0-1.414l8-8a1 1 0 0 1 1.414 0Z" clip-rule="evenodd"/></svg>
+1
assets/icons/chevronRight_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
+1
assets/icons/circleInfo_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z" clip-rule="evenodd"/></svg>
+1
assets/icons/emojiSad_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z" clip-rule="evenodd"/></svg>
+1
assets/icons/eyeSlash_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2.293 2.293a1 1 0 0 1 1.414 0L7.335 5.92l.03.03 3.22 3.222 4.243 4.242 3.22 3.22.03.03 3.63 3.629a1 1 0 0 1-1.415 1.414l-3.09-3.09c-2.65 1.478-5.625 1.778-8.421.869-3.039-.987-5.779-3.37-7.67-7.027a1 1 0 0 1 0-.918c1.086-2.1 2.452-3.78 3.996-5.019L2.293 3.707a1 1 0 0 1 0-1.414Zm4.24 5.654 2.021 2.021a4 4 0 0 0 5.478 5.478l1.688 1.688c-2.042.982-4.246 1.124-6.32.45-2.34-.76-4.594-2.586-6.265-5.584.97-1.739 2.135-3.083 3.398-4.053Zm3.535 3.535 2.45 2.45a2 2 0 0 1-2.45-2.45Zm.81-5.405c3.573-.49 7.45 1.369 9.987 5.923a14.797 14.797 0 0 1-1.347 2.02 1 1 0 1 0 1.564 1.247 17.078 17.078 0 0 0 1.806-2.808 1 1 0 0 0 0-.918c-2.833-5.479-7.584-8.088-12.281-7.446a1 1 0 0 0 .271 1.982Z" clip-rule="evenodd"/></svg>
+1
assets/icons/filterTimeline_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.002 5a1 1 0 0 0-2 0v11.587l-1.295-1.294a1 1 0 0 0-1.414 1.414l3.002 3a1 1 0 0 0 1.414 0l2.998-3a1 1 0 0 0-1.414-1.414l-1.291 1.292V5ZM16 16a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2h-4Zm-3-4a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1Zm-1-6a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-8Z" clip-rule="evenodd"/></svg>
+1
assets/icons/growth_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h1a8.003 8.003 0 0 1 7.75 6.006A7.985 7.985 0 0 1 19 6h1a1 1 0 0 1 1 1v1a8 8 0 0 1-8 8v4a1 1 0 1 1-2 0v-7a8 8 0 0 1-8-8V4Zm2 1a6 6 0 0 1 6 6 6 6 0 0 1-6-6Zm8 9a6 6 0 0 1 6-6 6 6 0 0 1-6 6Z" clip-rule="evenodd"/></svg>
+1
assets/icons/hashtag_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M9.124 3.008a1 1 0 0 1 .868 1.116L9.632 7h5.985l.39-3.124a1 1 0 0 1 1.985.248L17.632 7H20a1 1 0 1 1 0 2h-2.617l-.75 6H20a1 1 0 1 1 0 2h-3.617l-.39 3.124a1 1 0 1 1-1.985-.248l.36-2.876H8.382l-.39 3.124a1 1 0 1 1-1.985-.248L6.368 17H4a1 1 0 1 1 0-2h2.617l.75-6H4a1 1 0 1 1 0-2h3.617l.39-3.124a1 1 0 0 1 1.117-.868ZM9.383 9l-.75 6h5.984l.75-6H9.383Z" clip-rule="evenodd"/></svg>
+1
assets/icons/listMagnifyingGlass_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm1 4a1 1 0 0 0 0 2h5a1 1 0 0 0 0-2H4Zm-1 7a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm9-8a4 4 0 1 1 7.446 2.032l.99.989a1 1 0 1 1-1.415 1.414l-.99-.989A4 4 0 0 1 12 12Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" clip-rule="evenodd"/></svg>
+1
assets/icons/listSparkle_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3a1 1 0 0 1 .92.606l1.342 3.132 3.132 1.343a1 1 0 0 1 0 1.838l-3.132 1.343-1.343 3.132a1 1 0 0 1-1.838 0l-1.343-3.132-3.132-1.343a1 1 0 0 1 0-1.838l3.132-1.343 1.343-3.132A1 1 0 0 1 17 9Zm0 3.539-.58 1.355a1 1 0 0 1-.526.525L14.539 15l1.355.58a1 1 0 0 1 .525.526L17 17.461l.58-1.355a1 1 0 0 1 .526-.525L19.461 15l-1.355-.58a1 1 0 0 1-.525-.526L17 12.539Z" clip-rule="evenodd"/></svg>
+1
assets/icons/loader_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 5a7 7 0 0 0-5.218 11.666A1 1 0 0 1 5.292 18a9 9 0 1 1 13.416 0 1 1 0 1 1-1.49-1.334A7 7 0 0 0 12 5Z" clip-rule="evenodd"/></svg>
+1
assets/icons/news2_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M1 5a1 1 0 0 1 1-1h7a3.99 3.99 0 0 1 3 1.354A3.99 3.99 0 0 1 15 4h7a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-6.723c-.52 0-1 .125-1.4.372-.421.26-.761.633-.983 1.075a1 1 0 0 1-1.788 0 2.664 2.664 0 0 0-.983-1.075c-.4-.247-.88-.372-1.4-.372H2a1 1 0 0 1-1-1V5Zm10 3a2 2 0 0 0-2-2H3v12h5.723c.776 0 1.564.173 2.277.569V8Zm2 10.569V8a2 2 0 0 1 2-2h6v12h-5.723c-.776 0-1.564.173-2.277.569Z" clip-rule="evenodd"/></svg>
+1
assets/icons/plusLarge_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
+1
assets/icons/trending2_stroke2_corner2_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="m18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z" clip-rule="evenodd"/></svg>
+22 -19
bskyweb/templates/base.html
··· 39 39 scrollbar-gutter: stable both-edges; 40 40 } 41 41 42 - /* Remove autofill styles on Webkit */ 43 - input:-webkit-autofill, 44 - input:-webkit-autofill:hover, 45 - input:-webkit-autofill:focus, 46 - textarea:-webkit-autofill, 47 - textarea:-webkit-autofill:hover, 48 - textarea:-webkit-autofill:focus, 49 - select:-webkit-autofill, 50 - select:-webkit-autofill:hover, 51 - select:-webkit-autofill:focus { 52 - border: 0; 53 - -webkit-text-fill-color: transparent; 54 - -webkit-box-shadow: none; 55 - } 56 - /* Force left-align date/time inputs on iOS mobile */ 57 - input::-webkit-date-and-time-value { 58 - text-align: left; 59 - } 60 - 61 42 /* Color theming */ 62 43 :root { 63 44 --text: black; ··· 84 65 --backgroundLight: hsl(211, 20%, 20%); 85 66 color-scheme: dark; 86 67 } 68 + } 69 + 70 + ::selection { 71 + background-color: var(--backgroundLight); 72 + } 73 + 74 + /* Remove autofill styles on Webkit */ 75 + input:autofill, 76 + input:-webkit-autofill, 77 + input:-webkit-autofill:hover, 78 + input:-webkit-autofill:focus, 79 + input:-webkit-autofill:active{ 80 + -webkit-background-clip: text; 81 + -webkit-text-fill-color: var(--text); 82 + transition: background-color 5000s ease-in-out 0s; 83 + box-shadow: inset 0 0 20px 20px var(--background); 84 + background: var(--background); 85 + color: var(--text); 86 + } 87 + /* Force left-align date/time inputs on iOS mobile */ 88 + input::-webkit-date-and-time-value { 89 + text-align: left; 87 90 } 88 91 89 92 body {
+6 -10
src/alf/atoms.ts
··· 104 104 flex: { 105 105 display: 'flex', 106 106 }, 107 + flex_col: { 108 + flexDirection: 'column', 109 + }, 107 110 flex_row: { 108 111 flexDirection: 'row', 109 112 }, ··· 149 152 }, 150 153 text_2xs: { 151 154 fontSize: tokens.fontSize._2xs, 152 - lineHeight: tokens.fontSize._2xs, 153 155 }, 154 156 text_xs: { 155 157 fontSize: tokens.fontSize.xs, 156 - lineHeight: tokens.fontSize.xs, 157 158 }, 158 159 text_sm: { 159 160 fontSize: tokens.fontSize.sm, 160 - lineHeight: tokens.fontSize.sm, 161 161 }, 162 162 text_md: { 163 163 fontSize: tokens.fontSize.md, 164 - lineHeight: tokens.fontSize.md, 165 164 }, 166 165 text_lg: { 167 166 fontSize: tokens.fontSize.lg, 168 - lineHeight: tokens.fontSize.lg, 169 167 }, 170 168 text_xl: { 171 169 fontSize: tokens.fontSize.xl, 172 - lineHeight: tokens.fontSize.xl, 173 170 }, 174 171 text_2xl: { 175 172 fontSize: tokens.fontSize._2xl, 176 - lineHeight: tokens.fontSize._2xl, 177 173 }, 178 174 text_3xl: { 179 175 fontSize: tokens.fontSize._3xl, 180 - lineHeight: tokens.fontSize._3xl, 181 176 }, 182 177 text_4xl: { 183 178 fontSize: tokens.fontSize._4xl, 184 - lineHeight: tokens.fontSize._4xl, 185 179 }, 186 180 text_5xl: { 187 181 fontSize: tokens.fontSize._5xl, 188 - lineHeight: tokens.fontSize._5xl, 189 182 }, 190 183 leading_tight: { 184 + lineHeight: 1.15, 185 + }, 186 + leading_snug: { 191 187 lineHeight: 1.25, 192 188 }, 193 189 leading_normal: {
+1
src/alf/index.tsx
··· 2 2 import {Dimensions} from 'react-native' 3 3 import * as themes from '#/alf/themes' 4 4 5 + export * from '#/alf/types' 5 6 export * as tokens from '#/alf/tokens' 6 7 export {atoms} from '#/alf/atoms' 7 8 export * from '#/alf/util/platform'
+8
src/alf/tokens.ts
··· 142 142 ], 143 143 hover_value: '#B88BB6', 144 144 }, 145 + summer: { 146 + values: [ 147 + [0, '#FF6A56'], 148 + [0.3, '#FF9156'], 149 + [1, '#FFDD87'], 150 + ], 151 + hover_value: '#FF9156', 152 + }, 145 153 nordic: { 146 154 values: [ 147 155 [0, '#083367'],
+10
src/alf/types.ts
··· 1 + import {StyleProp, ViewStyle, TextStyle} from 'react-native' 2 + 1 3 type LiteralToCommon<T extends PropertyKey> = T extends number 2 4 ? number 3 5 : T extends string ··· 14 16 ? LiteralToCommon<T[K]> 15 17 : Mutable<T[K]> 16 18 } 19 + 20 + export type TextStyleProp = { 21 + style?: StyleProp<TextStyle> 22 + } 23 + 24 + export type ViewStyleProp = { 25 + style?: StyleProp<ViewStyle> 26 + }
+53 -22
src/components/Button.tsx
··· 9 9 View, 10 10 TextStyle, 11 11 StyleSheet, 12 + StyleProp, 12 13 } from 'react-native' 13 14 import LinearGradient from 'react-native-linear-gradient' 14 15 15 - import {useTheme, atoms as a, tokens, web, native} from '#/alf' 16 + import {useTheme, atoms as a, tokens, android, flatten} from '#/alf' 16 17 import {Props as SVGIconProps} from '#/components/icons/common' 17 18 18 19 export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' ··· 27 28 | 'gradient_nordic' 28 29 | 'gradient_bonfire' 29 30 export type ButtonSize = 'small' | 'large' 31 + export type ButtonShape = 'round' | 'square' | 'default' 30 32 export type VariantProps = { 31 33 /** 32 34 * The style variation of the button ··· 40 42 * The size of the button 41 43 */ 42 44 size?: ButtonSize 45 + /** 46 + * The shape of the button 47 + */ 48 + shape?: ButtonShape 43 49 } 44 50 45 51 export type ButtonProps = React.PropsWithChildren< ··· 47 53 AccessibilityProps & 48 54 VariantProps & { 49 55 label: string 56 + style?: StyleProp<ViewStyle> 50 57 } 51 58 > 52 59 export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} ··· 74 81 variant, 75 82 color, 76 83 size, 84 + shape = 'default', 77 85 label, 78 86 disabled = false, 87 + style, 79 88 ...rest 80 89 }: ButtonProps) { 81 90 const t = useTheme() ··· 175 184 if (!disabled) { 176 185 baseStyles.push({ 177 186 backgroundColor: light 178 - ? tokens.color.gray_100 187 + ? tokens.color.gray_50 179 188 : tokens.color.gray_900, 180 189 }) 181 190 hoverStyles.push({ 182 191 backgroundColor: light 183 - ? tokens.color.gray_200 192 + ? tokens.color.gray_100 184 193 : tokens.color.gray_950, 185 194 }) 186 195 } else { 187 196 baseStyles.push({ 188 197 backgroundColor: light 189 - ? tokens.color.gray_300 198 + ? tokens.color.gray_200 190 199 : tokens.color.gray_950, 191 200 }) 192 201 } ··· 197 206 198 207 if (!disabled) { 199 208 baseStyles.push(a.border, { 200 - borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500, 209 + borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700, 201 210 }) 202 211 hoverStyles.push(a.border, t.atoms.bg_contrast_50) 203 212 } else { ··· 262 271 } 263 272 } 264 273 265 - if (size === 'large') { 266 - baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm) 267 - } else if (size === 'small') { 268 - baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm) 274 + if (shape === 'default') { 275 + if (size === 'large') { 276 + baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md) 277 + } else if (size === 'small') { 278 + baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) 279 + } 280 + } else if (shape === 'round' || shape === 'square') { 281 + if (size === 'large') { 282 + if (shape === 'round') { 283 + baseStyles.push({height: 54, width: 54}) 284 + } else { 285 + baseStyles.push({height: 50, width: 50}) 286 + } 287 + } else if (size === 'small') { 288 + baseStyles.push({height: 40, width: 40}) 289 + } 290 + 291 + if (shape === 'round') { 292 + baseStyles.push(a.rounded_full) 293 + } else if (shape === 'square') { 294 + baseStyles.push(a.rounded_sm) 295 + } 269 296 } 270 297 271 298 return { ··· 278 305 } as ViewStyle, 279 306 ], 280 307 } 281 - }, [t, variant, color, size, disabled]) 308 + }, [t, variant, color, size, shape, disabled]) 282 309 283 310 const {gradientColors, gradientHoverColors, gradientLocations} = 284 311 React.useMemo(() => { ··· 334 361 disabled: disabled || false, 335 362 }} 336 363 style={[ 364 + flatten(style), 337 365 a.flex_row, 338 366 a.align_center, 367 + a.justify_center, 339 368 a.overflow_hidden, 340 369 a.justify_center, 341 370 ...baseStyles, ··· 462 491 } 463 492 464 493 if (size === 'large') { 465 - baseStyles.push( 466 - a.text_md, 467 - web({paddingBottom: 1}), 468 - native({marginTop: 2}), 469 - ) 494 + baseStyles.push(a.text_md, android({paddingBottom: 1})) 470 495 } else { 471 - baseStyles.push( 472 - a.text_md, 473 - web({paddingBottom: 1}), 474 - native({marginTop: 2}), 475 - ) 496 + baseStyles.push(a.text_sm, android({paddingBottom: 1})) 476 497 } 477 498 478 499 return StyleSheet.flatten(baseStyles) ··· 491 512 492 513 export function ButtonIcon({ 493 514 icon: Comp, 515 + position, 494 516 }: { 495 517 icon: React.ComponentType<SVGIconProps> 518 + position?: 'left' | 'right' 496 519 }) { 497 - const {size} = useButtonContext() 520 + const {size, disabled} = useButtonContext() 498 521 const textStyles = useSharedButtonTextStyles() 499 522 500 523 return ( 501 - <View style={[a.z_20]}> 524 + <View 525 + style={[ 526 + a.z_20, 527 + { 528 + opacity: disabled ? 0.7 : 1, 529 + marginLeft: position === 'left' ? -2 : 0, 530 + marginRight: position === 'right' ? -2 : 0, 531 + }, 532 + ]}> 502 533 <Comp 503 534 size={size === 'large' ? 'md' : 'sm'} 504 535 style={[{color: textStyles.color, pointerEvents: 'none'}]}
+10
src/components/Divider.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {atoms as a, useTheme} from '#/alf' 4 + import {ViewStyleProp} from '#/alf' 5 + 6 + export function Divider({style}: ViewStyleProp) { 7 + const t = useTheme() 8 + 9 + return <View style={[a.w_full, a.border_t, t.atoms.border, style]} /> 10 + }
+127 -55
src/components/Link.tsx
··· 1 1 import React from 'react' 2 2 import { 3 - Text, 4 - TextStyle, 5 - StyleProp, 6 3 GestureResponderEvent, 7 4 Linking, 5 + TouchableWithoutFeedback, 8 6 } from 'react-native' 9 7 import { 10 8 useLinkProps, ··· 13 11 } from '@react-navigation/native' 14 12 import {sanitizeUrl} from '@braintree/sanitize-url' 15 13 14 + import {useInteractionState} from '#/components/hooks/useInteractionState' 16 15 import {isWeb} from '#/platform/detection' 17 - import {useTheme, web, flatten} from '#/alf' 18 - import {Button, ButtonProps, useButtonContext} from '#/components/Button' 16 + import {useTheme, web, flatten, TextStyleProp} from '#/alf' 17 + import {Button, ButtonProps} from '#/components/Button' 19 18 import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types' 20 19 import { 21 20 convertBskyAppUrlIfNeeded, ··· 24 23 } from '#/lib/strings/url-helpers' 25 24 import {useModalControls} from '#/state/modals' 26 25 import {router} from '#/routes' 26 + import {Text} from '#/components/Typography' 27 27 28 - export type LinkProps = Omit< 29 - ButtonProps, 30 - 'style' | 'onPress' | 'disabled' | 'label' 28 + /** 29 + * Only available within a `Link`, since that inherits from `Button`. 30 + * `InlineLink` provides no context. 31 + */ 32 + export {useButtonContext as useLinkContext} from '#/components/Button' 33 + 34 + type BaseLinkProps = Pick< 35 + Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 36 + 'to' 31 37 > & { 32 38 /** 33 - * `TextStyle` to apply to the anchor element itself. Does not apply to any children. 34 - */ 35 - style?: StyleProp<TextStyle> 36 - /** 37 39 * The React Navigation `StackAction` to perform when the link is pressed. 38 40 */ 39 41 action?: 'push' | 'replace' | 'navigate' 42 + 40 43 /** 41 - * If true, will warn the user if the link text does not match the href. Only 42 - * works for Links with children that are strings i.e. text links. 44 + * If true, will warn the user if the link text does not match the href. 45 + * 46 + * Note: atm this only works for `InlineLink`s with a string child. 43 47 */ 44 48 warnOnMismatchingTextChild?: boolean 45 - label?: ButtonProps['label'] 46 - } & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'> 49 + } 47 50 48 - /** 49 - * A interactive element that renders as a `<a>` tag on the web. On mobile it 50 - * will translate the `href` to navigator screens and params and dispatch a 51 - * navigation action. 52 - * 53 - * Intended to behave as a web anchor tag. For more complex routing, use a 54 - * `Button`. 55 - */ 56 - export function Link({ 57 - children, 51 + export function useLink({ 58 52 to, 53 + displayText, 59 54 action = 'push', 60 55 warnOnMismatchingTextChild, 61 - style, 62 - ...rest 63 - }: LinkProps) { 56 + }: BaseLinkProps & { 57 + displayText: string 58 + }) { 64 59 const navigation = useNavigation<NavigationProp>() 65 60 const {href} = useLinkProps<AllNavigatorParams>({ 66 61 to: ··· 68 63 }) 69 64 const isExternal = isExternalUrl(href) 70 65 const {openModal, closeModal} = useModalControls() 66 + 71 67 const onPress = React.useCallback( 72 68 (e: GestureResponderEvent) => { 73 - const stringChildren = typeof children === 'string' ? children : '' 74 69 const requiresWarning = Boolean( 75 70 warnOnMismatchingTextChild && 76 - stringChildren && 71 + displayText && 77 72 isExternal && 78 - linkRequiresWarning(href, stringChildren), 73 + linkRequiresWarning(href, displayText), 79 74 ) 80 75 81 76 if (requiresWarning) { ··· 83 78 84 79 openModal({ 85 80 name: 'link-warning', 86 - text: stringChildren, 81 + text: displayText, 87 82 href: href, 88 83 }) 89 84 } else { ··· 134 129 warnOnMismatchingTextChild, 135 130 navigation, 136 131 action, 137 - children, 132 + displayText, 138 133 closeModal, 139 134 openModal, 140 135 ], 141 136 ) 142 137 138 + return { 139 + isExternal, 140 + href, 141 + onPress, 142 + } 143 + } 144 + 145 + export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> & 146 + Omit<ButtonProps, 'style' | 'onPress' | 'disabled' | 'label'> & { 147 + /** 148 + * Label for a11y. Defaults to the href. 149 + */ 150 + label?: string 151 + } 152 + 153 + /** 154 + * A interactive element that renders as a `<a>` tag on the web. On mobile it 155 + * will translate the `href` to navigator screens and params and dispatch a 156 + * navigation action. 157 + * 158 + * Intended to behave as a web anchor tag. For more complex routing, use a 159 + * `Button`. 160 + */ 161 + export function Link({children, to, action = 'push', ...rest}: LinkProps) { 162 + const {href, isExternal, onPress} = useLink({ 163 + to, 164 + displayText: typeof children === 'string' ? children : '', 165 + action, 166 + }) 167 + 143 168 return ( 144 169 <Button 145 170 label={href} ··· 158 183 noUnderline: '1', 159 184 }, 160 185 })}> 161 - {typeof children === 'string' ? ( 162 - <LinkText style={style}>{children}</LinkText> 163 - ) : ( 164 - children 165 - )} 186 + {children} 166 187 </Button> 167 188 ) 168 189 } 169 190 170 - function LinkText({ 191 + export type InlineLinkProps = React.PropsWithChildren< 192 + BaseLinkProps & 193 + TextStyleProp & { 194 + /** 195 + * Label for a11y. Defaults to the href. 196 + */ 197 + label?: string 198 + } 199 + > 200 + 201 + export function InlineLink({ 171 202 children, 203 + to, 204 + action = 'push', 205 + warnOnMismatchingTextChild, 172 206 style, 173 - }: React.PropsWithChildren<{ 174 - style?: StyleProp<TextStyle> 175 - }>) { 207 + ...rest 208 + }: InlineLinkProps) { 176 209 const t = useTheme() 177 - const {hovered} = useButtonContext() 210 + const stringChildren = typeof children === 'string' 211 + const {href, isExternal, onPress} = useLink({ 212 + to, 213 + displayText: stringChildren ? children : '', 214 + action, 215 + warnOnMismatchingTextChild, 216 + }) 217 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 218 + const { 219 + state: pressed, 220 + onIn: onPressIn, 221 + onOut: onPressOut, 222 + } = useInteractionState() 223 + 178 224 return ( 179 - <Text 180 - style={[ 181 - {color: t.palette.primary_500}, 182 - hovered && { 183 - textDecorationLine: 'underline', 184 - textDecorationColor: t.palette.primary_500, 185 - }, 186 - flatten(style), 187 - ]}> 188 - {children as string} 189 - </Text> 225 + <TouchableWithoutFeedback 226 + accessibilityRole="button" 227 + onPress={onPress} 228 + onPressIn={onPressIn} 229 + onPressOut={onPressOut} 230 + onFocus={onFocus} 231 + onBlur={onBlur}> 232 + <Text 233 + label={href} 234 + {...rest} 235 + style={[ 236 + {color: t.palette.primary_500}, 237 + (focused || pressed) && { 238 + outline: 0, 239 + textDecorationLine: 'underline', 240 + textDecorationColor: t.palette.primary_500, 241 + }, 242 + flatten(style), 243 + ]} 244 + role="link" 245 + accessibilityRole="link" 246 + href={href} 247 + {...web({ 248 + hrefAttrs: { 249 + target: isExternal ? 'blank' : undefined, 250 + rel: isExternal ? 'noopener noreferrer' : undefined, 251 + }, 252 + dataSet: stringChildren 253 + ? {} 254 + : { 255 + // default to no underline, apply this ourselves 256 + noUnderline: '1', 257 + }, 258 + })}> 259 + {children} 260 + </Text> 261 + </TouchableWithoutFeedback> 190 262 ) 191 263 }
+44 -35
src/components/Portal.tsx
··· 12 12 [id: string]: Component 13 13 } 14 14 15 - export const Context = React.createContext<ContextType>({ 16 - outlet: null, 17 - append: () => {}, 18 - remove: () => {}, 19 - }) 15 + export function createPortalGroup() { 16 + const Context = React.createContext<ContextType>({ 17 + outlet: null, 18 + append: () => {}, 19 + remove: () => {}, 20 + }) 20 21 21 - export function Provider(props: React.PropsWithChildren<{}>) { 22 - const map = React.useRef<ComponentMap>({}) 23 - const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null) 22 + function Provider(props: React.PropsWithChildren<{}>) { 23 + const map = React.useRef<ComponentMap>({}) 24 + const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null) 24 25 25 - const append = React.useCallback<ContextType['append']>((id, component) => { 26 - if (map.current[id]) return 27 - map.current[id] = <React.Fragment key={id}>{component}</React.Fragment> 28 - setOutlet(<>{Object.values(map.current)}</>) 29 - }, []) 26 + const append = React.useCallback<ContextType['append']>((id, component) => { 27 + if (map.current[id]) return 28 + map.current[id] = <React.Fragment key={id}>{component}</React.Fragment> 29 + setOutlet(<>{Object.values(map.current)}</>) 30 + }, []) 30 31 31 - const remove = React.useCallback<ContextType['remove']>(id => { 32 - delete map.current[id] 33 - setOutlet(<>{Object.values(map.current)}</>) 34 - }, []) 32 + const remove = React.useCallback<ContextType['remove']>(id => { 33 + delete map.current[id] 34 + setOutlet(<>{Object.values(map.current)}</>) 35 + }, []) 36 + 37 + return ( 38 + <Context.Provider value={{outlet, append, remove}}> 39 + {props.children} 40 + </Context.Provider> 41 + ) 42 + } 43 + 44 + function Outlet() { 45 + const ctx = React.useContext(Context) 46 + return ctx.outlet 47 + } 35 48 36 - return ( 37 - <Context.Provider value={{outlet, append, remove}}> 38 - {props.children} 39 - </Context.Provider> 40 - ) 41 - } 49 + function Portal({children}: React.PropsWithChildren<{}>) { 50 + const {append, remove} = React.useContext(Context) 51 + const id = React.useId() 52 + React.useEffect(() => { 53 + append(id, children as Component) 54 + return () => remove(id) 55 + }, [id, children, append, remove]) 56 + return null 57 + } 42 58 43 - export function Outlet() { 44 - const ctx = React.useContext(Context) 45 - return ctx.outlet 59 + return {Provider, Outlet, Portal} 46 60 } 47 61 48 - export function Portal({children}: React.PropsWithChildren<{}>) { 49 - const {append, remove} = React.useContext(Context) 50 - const id = React.useId() 51 - React.useEffect(() => { 52 - append(id, children as Component) 53 - return () => remove(id) 54 - }, [id, children, append, remove]) 55 - return null 56 - } 62 + const DefaultPortal = createPortalGroup() 63 + export const Provider = DefaultPortal.Provider 64 + export const Outlet = DefaultPortal.Outlet 65 + export const Portal = DefaultPortal.Portal
+131
src/components/RichText.tsx
··· 1 + import React from 'react' 2 + import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api' 3 + 4 + import {atoms as a, TextStyleProp} from '#/alf' 5 + import {InlineLink} from '#/components/Link' 6 + import {Text} from '#/components/Typography' 7 + import {toShortUrl} from 'lib/strings/url-helpers' 8 + import {getAgent} from '#/state/session' 9 + 10 + const WORD_WRAP = {wordWrap: 1} 11 + 12 + export function RichText({ 13 + testID, 14 + value, 15 + style, 16 + numberOfLines, 17 + disableLinks, 18 + resolveFacets = false, 19 + }: TextStyleProp & { 20 + value: RichTextAPI | string 21 + testID?: string 22 + numberOfLines?: number 23 + disableLinks?: boolean 24 + resolveFacets?: boolean 25 + }) { 26 + const detected = React.useRef(false) 27 + const [richText, setRichText] = React.useState<RichTextAPI>(() => 28 + value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), 29 + ) 30 + const styles = [a.leading_normal, style] 31 + 32 + React.useEffect(() => { 33 + if (!resolveFacets) return 34 + 35 + async function detectFacets() { 36 + const rt = new RichTextAPI({text: richText.text}) 37 + await rt.detectFacets(getAgent()) 38 + setRichText(rt) 39 + } 40 + 41 + if (!detected.current) { 42 + detected.current = true 43 + detectFacets() 44 + } 45 + }, [richText, setRichText, resolveFacets]) 46 + 47 + const {text, facets} = richText 48 + 49 + if (!facets?.length) { 50 + if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) { 51 + return ( 52 + <Text 53 + testID={testID} 54 + style={[ 55 + { 56 + fontSize: 26, 57 + lineHeight: 30, 58 + }, 59 + ]} 60 + // @ts-ignore web only -prf 61 + dataSet={WORD_WRAP}> 62 + {text} 63 + </Text> 64 + ) 65 + } 66 + return ( 67 + <Text 68 + testID={testID} 69 + style={styles} 70 + numberOfLines={numberOfLines} 71 + // @ts-ignore web only -prf 72 + dataSet={WORD_WRAP}> 73 + {text} 74 + </Text> 75 + ) 76 + } 77 + 78 + const els = [] 79 + let key = 0 80 + // N.B. must access segments via `richText.segments`, not via destructuring 81 + for (const segment of richText.segments()) { 82 + const link = segment.link 83 + const mention = segment.mention 84 + if ( 85 + mention && 86 + AppBskyRichtextFacet.validateMention(mention).success && 87 + !disableLinks 88 + ) { 89 + els.push( 90 + <InlineLink 91 + key={key} 92 + to={`/profile/${mention.did}`} 93 + style={[...styles, {pointerEvents: 'auto'}]} 94 + // @ts-ignore TODO 95 + dataSet={WORD_WRAP}> 96 + {segment.text} 97 + </InlineLink>, 98 + ) 99 + } else if (link && AppBskyRichtextFacet.validateLink(link).success) { 100 + if (disableLinks) { 101 + els.push(toShortUrl(segment.text)) 102 + } else { 103 + els.push( 104 + <InlineLink 105 + key={key} 106 + to={link.uri} 107 + style={[...styles, {pointerEvents: 'auto'}]} 108 + // @ts-ignore TODO 109 + dataSet={WORD_WRAP} 110 + warnOnMismatchingLabel> 111 + {toShortUrl(segment.text)} 112 + </InlineLink>, 113 + ) 114 + } 115 + } else { 116 + els.push(segment.text) 117 + } 118 + key++ 119 + } 120 + 121 + return ( 122 + <Text 123 + testID={testID} 124 + style={styles} 125 + numberOfLines={numberOfLines} 126 + // @ts-ignore web only -prf 127 + dataSet={WORD_WRAP}> 128 + {els} 129 + </Text> 130 + ) 131 + }
+83 -13
src/components/Typography.tsx
··· 1 1 import React from 'react' 2 - import {Text as RNText, TextProps} from 'react-native' 2 + import {Text as RNText, TextStyle, TextProps} from 'react-native' 3 3 4 4 import {useTheme, atoms, web, flatten} from '#/alf' 5 5 6 + /** 7 + * Util to calculate lineHeight from a text size atom and a leading atom 8 + * 9 + * Example: 10 + * `leading(atoms.text_md, atoms.leading_normal)` // => 24 11 + */ 12 + export function leading< 13 + Size extends {fontSize?: number}, 14 + Leading extends {lineHeight?: number}, 15 + >(textSize: Size, leading: Leading) { 16 + const size = textSize?.fontSize || atoms.text_md.fontSize 17 + const lineHeight = leading?.lineHeight || atoms.leading_normal.lineHeight 18 + return size * lineHeight 19 + } 20 + 21 + /** 22 + * Ensures that `lineHeight` defaults to a relative value of `1`, or applies 23 + * other relative leading atoms. 24 + * 25 + * If the `lineHeight` value is > 2, we assume it's an absolute value and 26 + * returns it as-is. 27 + */ 28 + function normalizeTextStyles(styles: TextStyle[]) { 29 + const s = flatten(styles) 30 + // should always be defined on these components 31 + const fontSize = s.fontSize || atoms.text_md.fontSize 32 + 33 + if (s?.lineHeight) { 34 + if (s.lineHeight <= 2) { 35 + s.lineHeight = fontSize * s.lineHeight 36 + } 37 + } else { 38 + s.lineHeight = fontSize 39 + } 40 + 41 + return s 42 + } 43 + 6 44 export function Text({style, ...rest}: TextProps) { 7 45 const t = useTheme() 8 - return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} /> 46 + const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)]) 47 + return <RNText style={s} {...rest} /> 9 48 } 10 49 11 50 export function H1({style, ...rest}: TextProps) { ··· 19 58 <RNText 20 59 {...attr} 21 60 {...rest} 22 - style={[atoms.text_5xl, atoms.font_bold, t.atoms.text, flatten(style)]} 61 + style={normalizeTextStyles([ 62 + atoms.text_5xl, 63 + atoms.font_bold, 64 + t.atoms.text, 65 + flatten(style), 66 + ])} 23 67 /> 24 68 ) 25 69 } ··· 35 79 <RNText 36 80 {...attr} 37 81 {...rest} 38 - style={[atoms.text_4xl, atoms.font_bold, t.atoms.text, flatten(style)]} 82 + style={normalizeTextStyles([ 83 + atoms.text_4xl, 84 + atoms.font_bold, 85 + t.atoms.text, 86 + flatten(style), 87 + ])} 39 88 /> 40 89 ) 41 90 } ··· 51 100 <RNText 52 101 {...attr} 53 102 {...rest} 54 - style={[atoms.text_3xl, atoms.font_bold, t.atoms.text, flatten(style)]} 103 + style={normalizeTextStyles([ 104 + atoms.text_3xl, 105 + atoms.font_bold, 106 + t.atoms.text, 107 + flatten(style), 108 + ])} 55 109 /> 56 110 ) 57 111 } ··· 67 121 <RNText 68 122 {...attr} 69 123 {...rest} 70 - style={[atoms.text_2xl, atoms.font_bold, t.atoms.text, flatten(style)]} 124 + style={normalizeTextStyles([ 125 + atoms.text_2xl, 126 + atoms.font_bold, 127 + t.atoms.text, 128 + flatten(style), 129 + ])} 71 130 /> 72 131 ) 73 132 } ··· 83 142 <RNText 84 143 {...attr} 85 144 {...rest} 86 - style={[atoms.text_xl, atoms.font_bold, t.atoms.text, flatten(style)]} 145 + style={normalizeTextStyles([ 146 + atoms.text_xl, 147 + atoms.font_bold, 148 + t.atoms.text, 149 + flatten(style), 150 + ])} 87 151 /> 88 152 ) 89 153 } ··· 99 163 <RNText 100 164 {...attr} 101 165 {...rest} 102 - style={[atoms.text_lg, atoms.font_bold, t.atoms.text, flatten(style)]} 166 + style={normalizeTextStyles([ 167 + atoms.text_lg, 168 + atoms.font_bold, 169 + t.atoms.text, 170 + flatten(style), 171 + ])} 103 172 /> 104 173 ) 105 174 } ··· 110 179 web({ 111 180 role: 'paragraph', 112 181 }) || {} 113 - const _style = flatten(style) 114 - const lineHeight = 115 - (_style?.lineHeight || atoms.text_md.lineHeight) * 116 - atoms.leading_normal.lineHeight 117 182 return ( 118 183 <RNText 119 184 {...attr} 120 185 {...rest} 121 - style={[atoms.text_md, t.atoms.text, _style, {lineHeight}]} 186 + style={normalizeTextStyles([ 187 + atoms.text_md, 188 + atoms.leading_normal, 189 + t.atoms.text, 190 + flatten(style), 191 + ])} 122 192 /> 123 193 ) 124 194 }
+1 -1
src/components/forms/TextField.tsx
··· 208 208 paddingBottom: 2, 209 209 }), 210 210 { 211 - lineHeight: a.text_md.lineHeight * 1.1875, 211 + lineHeight: a.text_md.fontSize * 1.1875, 212 212 textAlignVertical: rest.multiline ? 'top' : undefined, 213 213 minHeight: rest.multiline ? 60 : undefined, 214 214 },
+4 -4
src/components/forms/Toggle.tsx
··· 2 2 import {Pressable, View, ViewStyle} from 'react-native' 3 3 4 4 import {HITSLOP_10} from 'lib/constants' 5 - import {useTheme, atoms as a, web, native} from '#/alf' 5 + import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf' 6 6 import {Text} from '#/components/Typography' 7 7 import {useInteractionState} from '#/components/hooks/useInteractionState' 8 8 ··· 49 49 label: string 50 50 }> 51 51 52 - export type ItemProps = { 52 + export type ItemProps = ViewStyleProp & { 53 53 type?: 'radio' | 'checkbox' 54 54 name: string 55 55 label: string ··· 57 57 disabled?: boolean 58 58 onChange?: (selected: boolean) => void 59 59 isInvalid?: boolean 60 - style?: (state: ItemState) => ViewStyle 61 60 children: ((props: ItemState) => React.ReactNode) | React.ReactNode 62 61 } 63 62 ··· 125 124 return ( 126 125 <GroupContext.Provider value={context}> 127 126 <View 127 + style={[a.w_full]} 128 128 role={groupRole} 129 129 {...(groupRole === 'radiogroup' 130 130 ? { ··· 224 224 a.align_center, 225 225 a.gap_sm, 226 226 focused ? web({outline: 'none'}) : {}, 227 - style?.(state), 227 + flatten(style), 228 228 ]}> 229 229 {typeof children === 'function' ? children(state) : children} 230 230 </Pressable>
+5 -3
src/components/forms/ToggleButton.tsx
··· 20 20 <Toggle.Group type={multiple ? 'checkbox' : 'radio'} {...props}> 21 21 <View 22 22 style={[ 23 + a.w_full, 23 24 a.flex_row, 24 25 a.border, 25 26 a.rounded_sm, ··· 34 35 35 36 export function Button({children, ...props}: ItemProps) { 36 37 return ( 37 - <Toggle.Item {...props}> 38 + <Toggle.Item {...props} style={[a.flex_grow]}> 38 39 <ButtonInner>{children}</ButtonInner> 39 40 </Toggle.Item> 40 41 ) ··· 95 96 borderLeftWidth: 1, 96 97 marginLeft: -1, 97 98 }, 98 - a.px_lg, 99 + a.flex_grow, 99 100 a.py_md, 100 101 native({ 101 - paddingTop: 14, 102 + paddingBottom: 10, 102 103 }), 104 + a.px_sm, 103 105 t.atoms.bg, 104 106 t.atoms.border, 105 107 baseStyles,
+6
src/components/icons/ArrowRotateCounterClockwise.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded = 4 + createSinglePathSVG({ 5 + path: 'M5 3a1 1 0 0 1 1 1v1.423c.498-.46 1.02-.869 1.58-1.213C8.863 3.423 10.302 3 12.028 3a9 9 0 1 1-8.487 12 1 1 0 0 1 1.885-.667A7 7 0 1 0 12.028 5c-1.37 0-2.444.327-3.402.915-.474.29-.93.652-1.383 1.085H9a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z', 6 + })
+5
src/components/icons/At.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const At_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z', 5 + })
+5
src/components/icons/Check.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z', 5 + })
+9
src/components/icons/Chevron.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M15.707 3.293a1 1 0 0 1 0 1.414L8.414 12l7.293 7.293a1 1 0 0 1-1.414 1.414l-8-8a1 1 0 0 1 0-1.414l8-8a1 1 0 0 1 1.414 0Z', 5 + }) 6 + 7 + export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z', 9 + })
+5
src/components/icons/CircleInfo.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const CircleInfo_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z', 5 + })
+5
src/components/icons/Emoji.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z', 5 + })
+5
src/components/icons/EyeSlash.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const EyeSlash_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M2.293 2.293a1 1 0 0 1 1.414 0L7.335 5.92l.03.03 3.22 3.222 4.243 4.242 3.22 3.22.03.03 3.63 3.629a1 1 0 0 1-1.415 1.414l-3.09-3.09c-2.65 1.478-5.625 1.778-8.421.869-3.039-.987-5.779-3.37-7.67-7.027a1 1 0 0 1 0-.918c1.086-2.1 2.452-3.78 3.996-5.019L2.293 3.707a1 1 0 0 1 0-1.414Zm4.24 5.654 2.021 2.021a4 4 0 0 0 5.478 5.478l1.688 1.688c-2.042.982-4.246 1.124-6.32.45-2.34-.76-4.594-2.586-6.265-5.584.97-1.739 2.135-3.083 3.398-4.053Zm3.535 3.535 2.45 2.45a2 2 0 0 1-2.45-2.45Zm.81-5.405c3.573-.49 7.45 1.369 9.987 5.923a14.797 14.797 0 0 1-1.347 2.02 1 1 0 1 0 1.564 1.247 17.078 17.078 0 0 0 1.806-2.808 1 1 0 0 0 0-.918c-2.833-5.479-7.584-8.088-12.281-7.446a1 1 0 0 0 .271 1.982Z', 5 + })
+5
src/components/icons/FilterTimeline.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const FilterTimeline_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M7.002 5a1 1 0 0 0-2 0v11.587l-1.295-1.294a1 1 0 0 0-1.414 1.414l3.002 3a1 1 0 0 0 1.414 0l2.998-3a1 1 0 0 0-1.414-1.414l-1.291 1.292V5ZM16 16a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2h-4Zm-3-4a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1Zm-1-6a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-8Z', 5 + })
+5
src/components/icons/Growth.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Growth_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M3 4a1 1 0 0 1 1-1h1a8.003 8.003 0 0 1 7.75 6.006A7.985 7.985 0 0 1 19 6h1a1 1 0 0 1 1 1v1a8 8 0 0 1-8 8v4a1 1 0 1 1-2 0v-7a8 8 0 0 1-8-8V4Zm2 1a6 6 0 0 1 6 6 6 6 0 0 1-6-6Zm8 9a6 6 0 0 1 6-6 6 6 0 0 1-6 6Z', 5 + })
+5
src/components/icons/Hashtag.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Hashtag_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M9.124 3.008a1 1 0 0 1 .868 1.116L9.632 7h5.985l.39-3.124a1 1 0 0 1 1.985.248L17.632 7H20a1 1 0 1 1 0 2h-2.617l-.75 6H20a1 1 0 1 1 0 2h-3.617l-.39 3.124a1 1 0 1 1-1.985-.248l.36-2.876H8.382l-.39 3.124a1 1 0 1 1-1.985-.248L6.368 17H4a1 1 0 1 1 0-2h2.617l.75-6H4a1 1 0 1 1 0-2h3.617l.39-3.124a1 1 0 0 1 1.117-.868ZM9.383 9l-.75 6h5.984l.75-6H9.383Z', 5 + })
+5
src/components/icons/ListMagnifyingGlass.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ListMagnifyingGlass_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M3 4a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm1 4a1 1 0 0 0 0 2h5a1 1 0 0 0 0-2H4Zm-1 7a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm9-8a4 4 0 1 1 7.446 2.032l.99.989a1 1 0 1 1-1.415 1.414l-.99-.989A4 4 0 0 1 12 12Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z', 5 + })
+5
src/components/icons/ListSparkle.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const ListSparkle_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3a1 1 0 0 1 .92.606l1.342 3.132 3.132 1.343a1 1 0 0 1 0 1.838l-3.132 1.343-1.343 3.132a1 1 0 0 1-1.838 0l-1.343-3.132-3.132-1.343a1 1 0 0 1 0-1.838l3.132-1.343 1.343-3.132A1 1 0 0 1 17 9Zm0 3.539-.58 1.355a1 1 0 0 1-.526.525L14.539 15l1.355.58a1 1 0 0 1 .525.526L17 17.461l.58-1.355a1 1 0 0 1 .526-.525L19.461 15l-1.355-.58a1 1 0 0 1-.525-.526L17 12.539Z', 5 + })
+5
src/components/icons/News2.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const News2_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M1 5a1 1 0 0 1 1-1h7a3.99 3.99 0 0 1 3 1.354A3.99 3.99 0 0 1 15 4h7a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-6.723c-.52 0-1 .125-1.4.372-.421.26-.761.633-.983 1.075a1 1 0 0 1-1.788 0 2.664 2.664 0 0 0-.983-1.075c-.4-.247-.88-.372-1.4-.372H2a1 1 0 0 1-1-1V5Zm10 3a2 2 0 0 0-2-2H3v12h5.723c.776 0 1.564.173 2.277.569V8Zm2 10.569V8a2 2 0 0 1 2-2h6v12h-5.723c-.776 0-1.564.173-2.277.569Z', 5 + })
+5
src/components/icons/Plus.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z', 5 + })
+5
src/components/icons/Trending2.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({ 4 + path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z', 5 + })
+32
src/lib/analytics/types.ts
··· 131 131 'Onboarding:Reset': {} 132 132 'Onboarding:SuggestedFollowFollowed': {} 133 133 'Onboarding:CustomFeedAdded': {} 134 + // Onboarding v2 135 + 'OnboardingV2:Begin': {} 136 + 'OnboardingV2:StepInterests:Start': {} 137 + 'OnboardingV2:StepInterests:End': { 138 + selectedInterests: string[] 139 + selectedInterestsLength: number 140 + } 141 + 'OnboardingV2:StepInterests:Error': {} 142 + 'OnboardingV2:StepSuggestedAccounts:Start': {} 143 + 'OnboardingV2:StepSuggestedAccounts:End': { 144 + selectedAccountsLength: number 145 + } 146 + 'OnboardingV2:StepFollowingFeed:Start': {} 147 + 'OnboardingV2:StepFollowingFeed:End': {} 148 + 'OnboardingV2:StepAlgoFeeds:Start': {} 149 + 'OnboardingV2:StepAlgoFeeds:End': { 150 + selectedPrimaryFeeds: string[] 151 + selectedPrimaryFeedsLength: number 152 + selectedSecondaryFeeds: string[] 153 + selectedSecondaryFeedsLength: number 154 + } 155 + 'OnboardingV2:StepTopicalFeeds:Start': {} 156 + 'OnboardingV2:StepTopicalFeeds:End': { 157 + selectedFeeds: string[] 158 + selectedFeedsLength: number 159 + } 160 + 'OnboardingV2:StepModeration:Start': {} 161 + 'OnboardingV2:StepModeration:End': {} 162 + 'OnboardingV2:StepFinished:Start': {} 163 + 'OnboardingV2:StepFinished:End': {} 164 + 'OnboardingV2:Complete': {} 165 + 'OnboardingV2:Skip': {} 134 166 } 135 167 136 168 interface ScreenPropertiesMap {
+1
src/lib/build-flags.ts
··· 1 1 export const LOGIN_INCLUDE_DEV_SERVERS = true 2 2 export const PWI_ENABLED = true 3 + export const NEW_ONBOARDING_ENABLED = false
+51
src/screens/Onboarding/IconCircle.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + 4 + import { 5 + useTheme, 6 + atoms as a, 7 + ViewStyleProp, 8 + TextStyleProp, 9 + flatten, 10 + } from '#/alf' 11 + import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' 12 + import {Props} from '#/components/icons/common' 13 + 14 + export function IconCircle({ 15 + icon: Icon, 16 + size = 'xl', 17 + style, 18 + iconStyle, 19 + }: ViewStyleProp & { 20 + icon: typeof Growth 21 + size?: Props['size'] 22 + iconStyle?: TextStyleProp['style'] 23 + }) { 24 + const t = useTheme() 25 + 26 + return ( 27 + <View 28 + style={[ 29 + a.justify_center, 30 + a.align_center, 31 + a.rounded_full, 32 + { 33 + width: 64, 34 + height: 64, 35 + backgroundColor: 36 + t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950, 37 + }, 38 + flatten(style), 39 + ]}> 40 + <Icon 41 + size={size} 42 + style={[ 43 + { 44 + color: t.palette.primary_500, 45 + }, 46 + flatten(iconStyle), 47 + ]} 48 + /> 49 + </View> 50 + ) 51 + }
+231
src/screens/Onboarding/Layout.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 + import {useLingui} from '@lingui/react' 5 + import {msg} from '@lingui/macro' 6 + 7 + import {IS_DEV} from '#/env' 8 + import {isWeb} from '#/platform/detection' 9 + import {useOnboardingDispatch} from '#/state/shell' 10 + 11 + import { 12 + useTheme, 13 + atoms as a, 14 + useBreakpoints, 15 + web, 16 + native, 17 + flatten, 18 + TextStyleProp, 19 + } from '#/alf' 20 + import {H2, P, leading} from '#/components/Typography' 21 + import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' 22 + import {Button, ButtonIcon} from '#/components/Button' 23 + import {ScrollView} from '#/view/com/util/Views' 24 + import {createPortalGroup} from '#/components/Portal' 25 + 26 + import {Context} from '#/screens/Onboarding/state' 27 + 28 + const COL_WIDTH = 500 29 + 30 + export const OnboardingControls = createPortalGroup() 31 + 32 + export function Layout({children}: React.PropsWithChildren<{}>) { 33 + const {_} = useLingui() 34 + const t = useTheme() 35 + const insets = useSafeAreaInsets() 36 + const {gtMobile} = useBreakpoints() 37 + const onboardDispatch = useOnboardingDispatch() 38 + const {state, dispatch} = React.useContext(Context) 39 + const scrollview = React.useRef<ScrollView>(null) 40 + const prevActiveStep = React.useRef<string>(state.activeStep) 41 + 42 + React.useEffect(() => { 43 + if (state.activeStep !== prevActiveStep.current) { 44 + prevActiveStep.current = state.activeStep 45 + scrollview.current?.scrollTo({y: 0, animated: false}) 46 + } 47 + }, [state]) 48 + 49 + const paddingTop = gtMobile ? a.py_5xl : a.py_lg 50 + const dialogLabel = _(msg`Set up your account`) 51 + 52 + return ( 53 + <View 54 + aria-modal 55 + role="dialog" 56 + aria-role="dialog" 57 + aria-label={dialogLabel} 58 + accessibilityLabel={dialogLabel} 59 + accessibilityHint={_( 60 + msg`The following steps will help customize your Bluesky experience.`, 61 + )} 62 + style={[ 63 + // @ts-ignore web only -prf 64 + isWeb ? a.fixed : a.absolute, 65 + a.inset_0, 66 + a.flex_1, 67 + t.atoms.bg, 68 + ]}> 69 + {IS_DEV && ( 70 + <View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}> 71 + <Button 72 + variant="ghost" 73 + color="negative" 74 + size="small" 75 + onPress={() => onboardDispatch({type: 'skip'})} 76 + // DEV ONLY 77 + label="Clear onboarding state"> 78 + Clear 79 + </Button> 80 + </View> 81 + )} 82 + 83 + {!gtMobile && state.hasPrev && ( 84 + <View 85 + style={[ 86 + web(a.fixed), 87 + native(a.absolute), 88 + a.flex_row, 89 + a.w_full, 90 + a.justify_center, 91 + a.z_20, 92 + a.px_xl, 93 + { 94 + top: paddingTop.paddingTop + insets.top - 1, 95 + }, 96 + ]}> 97 + <View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}> 98 + <Button 99 + key={state.activeStep} // remove focus state on nav 100 + variant="ghost" 101 + color="secondary" 102 + size="small" 103 + shape="round" 104 + label={_(msg`Go back to previous step`)} 105 + style={[a.absolute]} 106 + onPress={() => dispatch({type: 'prev'})}> 107 + <ButtonIcon icon={ChevronLeft} /> 108 + </Button> 109 + </View> 110 + </View> 111 + )} 112 + 113 + <ScrollView 114 + ref={scrollview} 115 + style={[a.h_full, a.w_full, {paddingTop: insets.top}]} 116 + contentContainerStyle={{borderWidth: 0}} 117 + // @ts-ignore web only --prf 118 + dataSet={{'stable-gutters': 1}}> 119 + <View 120 + style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}> 121 + <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}> 122 + <View style={[a.w_full, a.align_center, paddingTop]}> 123 + <View 124 + style={[ 125 + a.flex_row, 126 + a.gap_sm, 127 + a.w_full, 128 + {paddingTop: 17, maxWidth: '60%'}, 129 + ]}> 130 + {Array(state.totalSteps) 131 + .fill(0) 132 + .map((_, i) => ( 133 + <View 134 + key={i} 135 + style={[ 136 + a.flex_1, 137 + a.pt_xs, 138 + a.rounded_full, 139 + t.atoms.bg_contrast_50, 140 + { 141 + backgroundColor: 142 + i + 1 <= state.activeStepIndex 143 + ? t.palette.primary_500 144 + : t.palette.contrast_100, 145 + }, 146 + ]} 147 + /> 148 + ))} 149 + </View> 150 + </View> 151 + 152 + <View 153 + style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}> 154 + {children} 155 + </View> 156 + 157 + <View style={{height: 200}} /> 158 + </View> 159 + </View> 160 + </ScrollView> 161 + 162 + <View 163 + style={[ 164 + // @ts-ignore web only -prf 165 + isWeb ? a.fixed : a.absolute, 166 + {bottom: 0, left: 0, right: 0}, 167 + t.atoms.bg, 168 + t.atoms.border, 169 + a.border_t, 170 + a.align_center, 171 + gtMobile ? a.px_5xl : a.px_xl, 172 + isWeb 173 + ? a.py_2xl 174 + : { 175 + paddingTop: a.pt_lg.paddingTop, 176 + paddingBottom: insets.bottom, 177 + }, 178 + ]}> 179 + <View 180 + style={[ 181 + a.w_full, 182 + {maxWidth: COL_WIDTH}, 183 + gtMobile && [a.flex_row, a.justify_between], 184 + ]}> 185 + {gtMobile && 186 + (state.hasPrev ? ( 187 + <Button 188 + key={state.activeStep} // remove focus state on nav 189 + variant="solid" 190 + color="secondary" 191 + size="large" 192 + shape="round" 193 + label={_(msg`Go back to previous step`)} 194 + onPress={() => dispatch({type: 'prev'})}> 195 + <ButtonIcon icon={ChevronLeft} /> 196 + </Button> 197 + ) : ( 198 + <View style={{height: 54}} /> 199 + ))} 200 + <OnboardingControls.Outlet /> 201 + </View> 202 + </View> 203 + </View> 204 + ) 205 + } 206 + 207 + export function Title({ 208 + children, 209 + style, 210 + }: React.PropsWithChildren<TextStyleProp>) { 211 + return ( 212 + <H2 213 + style={[ 214 + a.pb_sm, 215 + { 216 + lineHeight: leading(a.text_4xl, a.leading_tight), 217 + }, 218 + flatten(style), 219 + ]}> 220 + {children} 221 + </H2> 222 + ) 223 + } 224 + 225 + export function Description({ 226 + children, 227 + style, 228 + }: React.PropsWithChildren<TextStyleProp>) { 229 + const t = useTheme() 230 + return <P style={[t.atoms.text_contrast_700, flatten(style)]}>{children}</P> 231 + }
+378
src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import LinearGradient from 'react-native-linear-gradient' 4 + import {Image} from 'expo-image' 5 + import {useLingui} from '@lingui/react' 6 + import {msg} from '@lingui/macro' 7 + 8 + import {useTheme, atoms as a} from '#/alf' 9 + import * as Toggle from '#/components/forms/Toggle' 10 + import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' 11 + import {Text, H3} from '#/components/Typography' 12 + import {RichText} from '#/components/RichText' 13 + 14 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 15 + import {FeedConfig} from '#/screens/Onboarding/StepAlgoFeeds' 16 + 17 + function PrimaryFeedCardInner({ 18 + feed, 19 + config, 20 + }: { 21 + feed: FeedSourceInfo 22 + config: FeedConfig 23 + }) { 24 + const t = useTheme() 25 + const ctx = Toggle.useItemContext() 26 + 27 + const styles = React.useMemo( 28 + () => ({ 29 + active: [t.atoms.bg_contrast_25], 30 + selected: [ 31 + a.shadow_md, 32 + { 33 + backgroundColor: 34 + t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950, 35 + }, 36 + ], 37 + selectedHover: [ 38 + { 39 + backgroundColor: 40 + t.name === 'light' ? t.palette.primary_25 : t.palette.primary_975, 41 + }, 42 + ], 43 + textSelected: [{color: t.palette.white}], 44 + checkboxSelected: [ 45 + { 46 + borderColor: t.palette.white, 47 + }, 48 + ], 49 + }), 50 + [t], 51 + ) 52 + 53 + return ( 54 + <View 55 + style={[ 56 + a.relative, 57 + a.w_full, 58 + a.p_lg, 59 + a.rounded_md, 60 + a.overflow_hidden, 61 + t.atoms.bg_contrast_50, 62 + (ctx.hovered || ctx.focused || ctx.pressed) && styles.active, 63 + ctx.selected && styles.selected, 64 + ctx.selected && 65 + (ctx.hovered || ctx.focused || ctx.pressed) && 66 + styles.selectedHover, 67 + ]}> 68 + {ctx.selected && config.gradient && ( 69 + <LinearGradient 70 + colors={config.gradient.values.map(v => v[1])} 71 + locations={config.gradient.values.map(v => v[0])} 72 + start={{x: 0, y: 0}} 73 + end={{x: 1, y: 1}} 74 + style={[a.absolute, a.inset_0]} 75 + /> 76 + )} 77 + 78 + <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}> 79 + <View 80 + style={[ 81 + { 82 + width: 64, 83 + height: 64, 84 + }, 85 + a.rounded_sm, 86 + a.overflow_hidden, 87 + t.atoms.bg, 88 + ]}> 89 + <Image 90 + source={{uri: feed.avatar}} 91 + style={[a.w_full, a.h_full]} 92 + accessibilityIgnoresInvertColors 93 + /> 94 + </View> 95 + 96 + <View style={[a.pt_xs, a.flex_grow]}> 97 + <H3 98 + style={[ 99 + a.text_lg, 100 + a.font_bold, 101 + ctx.selected && styles.textSelected, 102 + ]}> 103 + {feed.displayName} 104 + </H3> 105 + 106 + <Text 107 + style={[ 108 + {opacity: 0.6}, 109 + a.text_md, 110 + a.py_xs, 111 + ctx.selected && styles.textSelected, 112 + ]}> 113 + by @{feed.creatorHandle} 114 + </Text> 115 + </View> 116 + 117 + <View 118 + style={[ 119 + { 120 + width: 28, 121 + height: 28, 122 + }, 123 + a.justify_center, 124 + a.align_center, 125 + a.rounded_sm, 126 + ctx.selected ? [a.border, styles.checkboxSelected] : t.atoms.bg, 127 + ]}> 128 + {ctx.selected && <Check size="sm" fill={t.palette.white} />} 129 + </View> 130 + </View> 131 + 132 + <View 133 + style={[ 134 + { 135 + opacity: ctx.selected ? 0.3 : 1, 136 + borderTopWidth: 1, 137 + }, 138 + a.mt_md, 139 + a.w_full, 140 + t.name === 'light' ? t.atoms.border : t.atoms.border_contrast, 141 + ctx.selected && { 142 + borderTopColor: t.palette.white, 143 + }, 144 + ]} 145 + /> 146 + 147 + <View style={[a.pt_md]}> 148 + <RichText 149 + value={feed.description} 150 + style={[ 151 + a.text_md, 152 + ctx.selected && 153 + (t.name === 'light' 154 + ? t.atoms.text_inverted 155 + : {color: t.palette.white}), 156 + ]} 157 + disableLinks 158 + /> 159 + </View> 160 + </View> 161 + ) 162 + } 163 + 164 + export function PrimaryFeedCard({config}: {config: FeedConfig}) { 165 + const {_} = useLingui() 166 + const {data: feed} = useFeedSourceInfoQuery({uri: config.uri}) 167 + 168 + return !feed ? ( 169 + <FeedCardPlaceholder primary /> 170 + ) : ( 171 + <Toggle.Item 172 + name={feed.uri} 173 + label={_(msg`Subscribe to the ${feed.displayName} feed`)}> 174 + <PrimaryFeedCardInner config={config} feed={feed} /> 175 + </Toggle.Item> 176 + ) 177 + } 178 + 179 + function FeedCardInner({feed}: {feed: FeedSourceInfo; config: FeedConfig}) { 180 + const t = useTheme() 181 + const ctx = Toggle.useItemContext() 182 + 183 + const styles = React.useMemo( 184 + () => ({ 185 + active: [t.atoms.bg_contrast_25], 186 + selected: [ 187 + { 188 + backgroundColor: 189 + t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950, 190 + }, 191 + ], 192 + selectedHover: [ 193 + { 194 + backgroundColor: 195 + t.name === 'light' ? t.palette.primary_25 : t.palette.primary_975, 196 + }, 197 + ], 198 + textSelected: [], 199 + checkboxSelected: [ 200 + { 201 + backgroundColor: t.palette.primary_500, 202 + }, 203 + ], 204 + }), 205 + [t], 206 + ) 207 + 208 + return ( 209 + <View 210 + style={[ 211 + a.relative, 212 + a.w_full, 213 + a.p_md, 214 + a.rounded_md, 215 + a.overflow_hidden, 216 + t.atoms.bg_contrast_50, 217 + (ctx.hovered || ctx.focused || ctx.pressed) && styles.active, 218 + ctx.selected && styles.selected, 219 + ctx.selected && 220 + (ctx.hovered || ctx.focused || ctx.pressed) && 221 + styles.selectedHover, 222 + ]}> 223 + <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}> 224 + <View 225 + style={[ 226 + { 227 + width: 44, 228 + height: 44, 229 + }, 230 + a.rounded_sm, 231 + a.overflow_hidden, 232 + t.atoms.bg, 233 + ]}> 234 + <Image 235 + source={{uri: feed.avatar}} 236 + style={[a.w_full, a.h_full]} 237 + accessibilityIgnoresInvertColors 238 + /> 239 + </View> 240 + 241 + <View style={[a.pt_2xs, a.flex_grow]}> 242 + <H3 243 + style={[ 244 + a.text_md, 245 + a.font_bold, 246 + ctx.selected && styles.textSelected, 247 + ]}> 248 + {feed.displayName} 249 + </H3> 250 + <Text 251 + style={[ 252 + {opacity: 0.8}, 253 + a.pt_xs, 254 + ctx.selected && styles.textSelected, 255 + ]}> 256 + @{feed.creatorHandle} 257 + </Text> 258 + </View> 259 + 260 + <View 261 + style={[ 262 + a.justify_center, 263 + a.align_center, 264 + a.rounded_sm, 265 + t.atoms.bg, 266 + ctx.selected && styles.checkboxSelected, 267 + { 268 + width: 28, 269 + height: 28, 270 + }, 271 + ]}> 272 + {ctx.selected && <Check size="sm" fill={t.palette.white} />} 273 + </View> 274 + </View> 275 + 276 + <View 277 + style={[ 278 + { 279 + opacity: ctx.selected ? 0.3 : 1, 280 + borderTopWidth: 1, 281 + }, 282 + a.mt_md, 283 + a.w_full, 284 + t.name === 'light' ? t.atoms.border : t.atoms.border_contrast, 285 + ctx.selected && { 286 + borderTopColor: t.palette.primary_200, 287 + }, 288 + ]} 289 + /> 290 + 291 + <View style={[a.pt_md]}> 292 + <RichText value={feed.description} disableLinks /> 293 + </View> 294 + </View> 295 + ) 296 + } 297 + 298 + export function FeedCard({config}: {config: FeedConfig}) { 299 + const {_} = useLingui() 300 + const {data: feed} = useFeedSourceInfoQuery({uri: config.uri}) 301 + 302 + return !feed ? ( 303 + <FeedCardPlaceholder /> 304 + ) : feed.avatar ? ( 305 + <Toggle.Item 306 + name={feed.uri} 307 + label={_(msg`Subscribe to the ${feed.displayName} feed`)}> 308 + <FeedCardInner config={config} feed={feed} /> 309 + </Toggle.Item> 310 + ) : null 311 + } 312 + 313 + export function FeedCardPlaceholder({primary}: {primary?: boolean}) { 314 + const t = useTheme() 315 + return ( 316 + <View 317 + style={[ 318 + a.relative, 319 + a.w_full, 320 + a.p_md, 321 + a.rounded_md, 322 + a.overflow_hidden, 323 + t.atoms.bg_contrast_25, 324 + ]}> 325 + <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}> 326 + <View 327 + style={[ 328 + { 329 + width: primary ? 64 : 44, 330 + height: primary ? 64 : 44, 331 + }, 332 + a.rounded_sm, 333 + a.overflow_hidden, 334 + t.atoms.bg_contrast_100, 335 + ]} 336 + /> 337 + 338 + <View style={[a.pt_2xs, a.flex_grow, a.gap_sm]}> 339 + <View 340 + style={[ 341 + {width: 100, height: primary ? 20 : 16}, 342 + a.rounded_sm, 343 + t.atoms.bg_contrast_100, 344 + ]} 345 + /> 346 + <View 347 + style={[ 348 + {width: 60, height: 12}, 349 + a.rounded_sm, 350 + t.atoms.bg_contrast_100, 351 + ]} 352 + /> 353 + </View> 354 + </View> 355 + 356 + <View 357 + style={[ 358 + { 359 + borderTopWidth: 1, 360 + }, 361 + a.mt_md, 362 + a.w_full, 363 + t.atoms.border, 364 + ]} 365 + /> 366 + 367 + <View style={[a.pt_md, a.gap_xs]}> 368 + <View 369 + style={[ 370 + {width: '60%', height: 12}, 371 + a.rounded_sm, 372 + t.atoms.bg_contrast_100, 373 + ]} 374 + /> 375 + </View> 376 + </View> 377 + ) 378 + }
+160
src/screens/Onboarding/StepAlgoFeeds/index.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useLingui} from '@lingui/react' 4 + import {msg, Trans} from '@lingui/macro' 5 + 6 + import {atoms as a, tokens, useTheme} from '#/alf' 7 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 8 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 9 + import * as Toggle from '#/components/forms/Toggle' 10 + import {Text} from '#/components/Typography' 11 + import {Loader} from '#/components/Loader' 12 + import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 13 + import {useAnalytics} from '#/lib/analytics/analytics' 14 + 15 + import {Context} from '#/screens/Onboarding/state' 16 + import { 17 + Title, 18 + Description, 19 + OnboardingControls, 20 + } from '#/screens/Onboarding/Layout' 21 + import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' 22 + import {IconCircle} from '#/screens/Onboarding/IconCircle' 23 + 24 + export type FeedConfig = { 25 + default: boolean 26 + uri: string 27 + gradient?: typeof tokens.gradients.midnight | typeof tokens.gradients.nordic 28 + } 29 + 30 + const PRIMARY_FEEDS: FeedConfig[] = [ 31 + { 32 + default: true, 33 + uri: 'at://did:plc:wqowuobffl66jv3kpsvo7ak4/app.bsky.feed.generator/the-algorithm', 34 + gradient: tokens.gradients.midnight, 35 + }, 36 + { 37 + default: false, 38 + uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot', 39 + gradient: tokens.gradients.midnight, 40 + }, 41 + ] 42 + 43 + const SECONDARY_FEEDS: FeedConfig[] = [ 44 + { 45 + default: false, 46 + uri: 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/infreq', 47 + }, 48 + { 49 + default: false, 50 + uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', 51 + }, 52 + { 53 + default: false, 54 + uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/best-of-follows', 55 + }, 56 + { 57 + default: false, 58 + uri: 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/catch-up', 59 + }, 60 + { 61 + default: false, 62 + uri: 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/at-bangers', 63 + }, 64 + ] 65 + 66 + export function StepAlgoFeeds() { 67 + const {_} = useLingui() 68 + const {track} = useAnalytics() 69 + const t = useTheme() 70 + const {state, dispatch} = React.useContext(Context) 71 + const [primaryFeedUris, setPrimaryFeedUris] = React.useState<string[]>( 72 + PRIMARY_FEEDS.map(f => (f.default ? f.uri : '')).filter(Boolean), 73 + ) 74 + const [secondaryFeedUris, setSeconaryFeedUris] = React.useState<string[]>([]) 75 + const [saving, setSaving] = React.useState(false) 76 + 77 + const saveFeeds = React.useCallback(async () => { 78 + setSaving(true) 79 + 80 + const uris = primaryFeedUris.concat(secondaryFeedUris) 81 + dispatch({type: 'setAlgoFeedsStepResults', feedUris: uris}) 82 + 83 + setSaving(false) 84 + dispatch({type: 'next'}) 85 + track('OnboardingV2:StepAlgoFeeds:End', { 86 + selectedPrimaryFeeds: primaryFeedUris, 87 + selectedPrimaryFeedsLength: primaryFeedUris.length, 88 + selectedSecondaryFeeds: secondaryFeedUris, 89 + selectedSecondaryFeedsLength: secondaryFeedUris.length, 90 + }) 91 + }, [primaryFeedUris, secondaryFeedUris, dispatch, track]) 92 + 93 + React.useEffect(() => { 94 + track('OnboardingV2:StepAlgoFeeds:Start') 95 + }, [track]) 96 + 97 + return ( 98 + <View style={[a.align_start]}> 99 + <IconCircle icon={ListSparkle} style={[a.mb_2xl]} /> 100 + 101 + <Title> 102 + <Trans>Choose your algorithmic feeds</Trans> 103 + </Title> 104 + <Description> 105 + <Trans> 106 + Feeds are created by users and can give you entirely new experiences. 107 + </Trans> 108 + </Description> 109 + 110 + <View style={[a.w_full, a.pb_2xl]}> 111 + <Toggle.Group 112 + values={primaryFeedUris} 113 + onChange={setPrimaryFeedUris} 114 + label={_(msg`Select your primary algorithmic feeds`)}> 115 + <Text 116 + style={[a.text_md, a.pt_4xl, a.pb_md, t.atoms.text_contrast_700]}> 117 + <Trans>We recommend "For You" by Skygaze:</Trans> 118 + </Text> 119 + <FeedCard config={PRIMARY_FEEDS[0]} /> 120 + <Text 121 + style={[a.text_md, a.pt_4xl, a.pb_lg, t.atoms.text_contrast_700]}> 122 + <Trans>Or you can try our "Discover" algorithm:</Trans> 123 + </Text> 124 + <FeedCard config={PRIMARY_FEEDS[1]} /> 125 + </Toggle.Group> 126 + 127 + <Toggle.Group 128 + values={secondaryFeedUris} 129 + onChange={setSeconaryFeedUris} 130 + label={_(msg`Select your secondary algorithmic feeds`)}> 131 + <Text 132 + style={[a.text_md, a.pt_4xl, a.pb_lg, t.atoms.text_contrast_700]}> 133 + <Trans>There are many feeds to try:</Trans> 134 + </Text> 135 + <View style={[a.gap_md]}> 136 + {SECONDARY_FEEDS.map(config => ( 137 + <FeedCard key={config.uri} config={config} /> 138 + ))} 139 + </View> 140 + </Toggle.Group> 141 + </View> 142 + 143 + <OnboardingControls.Portal> 144 + <Button 145 + disabled={saving} 146 + key={state.activeStep} // remove focus state on nav 147 + variant="gradient" 148 + color="gradient_sky" 149 + size="large" 150 + label={_(msg`Continue to the next step`)} 151 + onPress={saveFeeds}> 152 + <ButtonText> 153 + <Trans>Continue</Trans> 154 + </ButtonText> 155 + <ButtonIcon icon={saving ? Loader : ChevronRight} position="right" /> 156 + </Button> 157 + </OnboardingControls.Portal> 158 + </View> 159 + ) 160 + }
+158
src/screens/Onboarding/StepFinished.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useLingui} from '@lingui/react' 4 + import {msg, Trans} from '@lingui/macro' 5 + 6 + import {logger} from '#/logger' 7 + import {atoms as a, useTheme} from '#/alf' 8 + import {Button, ButtonText, ButtonIcon} from '#/components/Button' 9 + import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' 10 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 11 + import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' 12 + import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' 13 + import {Text} from '#/components/Typography' 14 + import {useOnboardingDispatch} from '#/state/shell' 15 + import {Loader} from '#/components/Loader' 16 + import {useSetSaveFeedsMutation} from '#/state/queries/preferences' 17 + import {getAgent} from '#/state/session' 18 + import {useAnalytics} from '#/lib/analytics/analytics' 19 + 20 + import {Context} from '#/screens/Onboarding/state' 21 + import { 22 + Title, 23 + Description, 24 + OnboardingControls, 25 + } from '#/screens/Onboarding/Layout' 26 + import {IconCircle} from '#/screens/Onboarding/IconCircle' 27 + import { 28 + bulkWriteFollows, 29 + sortPrimaryAlgorithmFeeds, 30 + } from '#/screens/Onboarding/util' 31 + 32 + export function StepFinished() { 33 + const {_} = useLingui() 34 + const t = useTheme() 35 + const {track} = useAnalytics() 36 + const {state, dispatch} = React.useContext(Context) 37 + const onboardDispatch = useOnboardingDispatch() 38 + const [saving, setSaving] = React.useState(false) 39 + const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation() 40 + 41 + const finishOnboarding = React.useCallback(async () => { 42 + setSaving(true) 43 + 44 + const { 45 + interestsStepResults, 46 + suggestedAccountsStepResults, 47 + algoFeedsStepResults, 48 + topicalFeedsStepResults, 49 + } = state 50 + const {selectedInterests} = interestsStepResults 51 + const selectedFeeds = [ 52 + ...sortPrimaryAlgorithmFeeds(algoFeedsStepResults.feedUris), 53 + ...topicalFeedsStepResults.feedUris, 54 + ] 55 + 56 + try { 57 + await Promise.all([ 58 + bulkWriteFollows(suggestedAccountsStepResults.accountDids), 59 + // these must be serial 60 + (async () => { 61 + await getAgent().setInterestsPref({tags: selectedInterests}) 62 + await saveFeeds({ 63 + saved: selectedFeeds, 64 + pinned: selectedFeeds, 65 + }) 66 + })(), 67 + ]) 68 + } catch (e: any) { 69 + logger.info(`onboarding: bulk save failed`) 70 + logger.error(e) 71 + // don't alert the user, just let them into their account 72 + } 73 + 74 + setSaving(false) 75 + dispatch({type: 'finish'}) 76 + onboardDispatch({type: 'finish'}) 77 + track('OnboardingV2:StepFinished:End') 78 + track('OnboardingV2:Complete') 79 + }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track]) 80 + 81 + React.useEffect(() => { 82 + track('OnboardingV2:StepFinished:Start') 83 + }, [track]) 84 + 85 + return ( 86 + <View style={[a.align_start]}> 87 + <IconCircle icon={Check} style={[a.mb_2xl]} /> 88 + 89 + <Title> 90 + <Trans>You're ready to go!</Trans> 91 + </Title> 92 + <Description> 93 + <Trans>We hope you have a wonderful time. Remember, Bluesky is:</Trans> 94 + </Description> 95 + 96 + <View style={[a.pt_5xl, a.gap_3xl]}> 97 + <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}> 98 + <IconCircle icon={Growth} size="lg" style={{width: 48, height: 48}} /> 99 + <View style={[a.flex_1, a.gap_xs]}> 100 + <Text style={[a.font_bold, a.text_lg]}> 101 + <Trans>Public</Trans> 102 + </Text> 103 + <Text 104 + style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}> 105 + <Trans> 106 + Your posts, likes, and blocks are public. Mutes are private. 107 + </Trans> 108 + </Text> 109 + </View> 110 + </View> 111 + <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}> 112 + <IconCircle icon={News} size="lg" style={{width: 48, height: 48}} /> 113 + <View style={[a.flex_1, a.gap_xs]}> 114 + <Text style={[a.font_bold, a.text_lg]}> 115 + <Trans>Open</Trans> 116 + </Text> 117 + <Text 118 + style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}> 119 + <Trans>Never lose access to your followers and data.</Trans> 120 + </Text> 121 + </View> 122 + </View> 123 + <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}> 124 + <IconCircle 125 + icon={Trending} 126 + size="lg" 127 + style={{width: 48, height: 48}} 128 + /> 129 + <View style={[a.flex_1, a.gap_xs]}> 130 + <Text style={[a.font_bold, a.text_lg]}> 131 + <Trans>Flexible</Trans> 132 + </Text> 133 + <Text 134 + style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}> 135 + <Trans>Choose the algorithms that power your custom feeds.</Trans> 136 + </Text> 137 + </View> 138 + </View> 139 + </View> 140 + 141 + <OnboardingControls.Portal> 142 + <Button 143 + disabled={saving} 144 + key={state.activeStep} // remove focus state on nav 145 + variant="gradient" 146 + color="gradient_sky" 147 + size="large" 148 + label={_(msg`Complete onboarding and start using your account`)} 149 + onPress={finishOnboarding}> 150 + <ButtonText> 151 + {saving ? <Trans>Finalizing</Trans> : <Trans>Let's go!</Trans>} 152 + </ButtonText> 153 + {saving && <ButtonIcon icon={Loader} position="right" />} 154 + </Button> 155 + </OnboardingControls.Portal> 156 + </View> 157 + ) 158 + }
+160
src/screens/Onboarding/StepFollowingFeed.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useLingui} from '@lingui/react' 4 + import {msg, Trans} from '@lingui/macro' 5 + 6 + import {atoms as a} from '#/alf' 7 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 8 + import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' 9 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 10 + import {Text} from '#/components/Typography' 11 + import {Divider} from '#/components/Divider' 12 + import * as Toggle from '#/components/forms/Toggle' 13 + import {useAnalytics} from '#/lib/analytics/analytics' 14 + 15 + import {Context} from '#/screens/Onboarding/state' 16 + import { 17 + Title, 18 + Description, 19 + OnboardingControls, 20 + } from '#/screens/Onboarding/Layout' 21 + import { 22 + usePreferencesQuery, 23 + useSetFeedViewPreferencesMutation, 24 + } from 'state/queries/preferences' 25 + import {IconCircle} from '#/screens/Onboarding/IconCircle' 26 + 27 + export function StepFollowingFeed() { 28 + const {_} = useLingui() 29 + const {track} = useAnalytics() 30 + const {dispatch} = React.useContext(Context) 31 + 32 + const {data: preferences} = usePreferencesQuery() 33 + const {mutate: setFeedViewPref, variables} = 34 + useSetFeedViewPreferencesMutation() 35 + 36 + const showReplies = !( 37 + variables?.hideReplies ?? preferences?.feedViewPrefs.hideReplies 38 + ) 39 + const showReposts = !( 40 + variables?.hideReposts ?? preferences?.feedViewPrefs.hideReposts 41 + ) 42 + const showQuotes = !( 43 + variables?.hideQuotePosts ?? preferences?.feedViewPrefs.hideQuotePosts 44 + ) 45 + 46 + const onContinue = React.useCallback(() => { 47 + dispatch({type: 'next'}) 48 + track('OnboardingV2:StepFollowingFeed:End') 49 + }, [track, dispatch]) 50 + 51 + React.useEffect(() => { 52 + track('OnboardingV2:StepFollowingFeed:Start') 53 + }, [track]) 54 + 55 + return ( 56 + // Hack for now to move the image container up 57 + <View style={[a.align_start]}> 58 + <IconCircle icon={FilterTimeline} style={[a.mb_2xl]} /> 59 + 60 + <Title> 61 + <Trans>Your default feed is "Following"</Trans> 62 + </Title> 63 + <Description style={[a.mb_md]}> 64 + <Trans>It show posts from the people your follow as they happen.</Trans> 65 + </Description> 66 + 67 + <View style={[a.w_full]}> 68 + <Toggle.Item 69 + name="Show Replies" // no need to translate 70 + label={_(msg`Show replies in Following feed`)} 71 + value={showReplies} 72 + onChange={() => { 73 + setFeedViewPref({ 74 + hideReplies: showReplies, 75 + }) 76 + }}> 77 + <View 78 + style={[ 79 + a.flex_row, 80 + a.w_full, 81 + a.py_lg, 82 + a.justify_between, 83 + a.align_center, 84 + ]}> 85 + <Text style={[a.text_md, a.font_bold]}> 86 + <Trans>Show replies in Following</Trans> 87 + </Text> 88 + <Toggle.Switch /> 89 + </View> 90 + </Toggle.Item> 91 + <Divider /> 92 + <Toggle.Item 93 + name="Show Reposts" // no need to translate 94 + label={_(msg`Show re-posts in Following feed`)} 95 + value={showReposts} 96 + onChange={() => { 97 + setFeedViewPref({ 98 + hideReposts: showReposts, 99 + }) 100 + }}> 101 + <View 102 + style={[ 103 + a.flex_row, 104 + a.w_full, 105 + a.py_lg, 106 + a.justify_between, 107 + a.align_center, 108 + ]}> 109 + <Text style={[a.text_md, a.font_bold]}> 110 + <Trans>Show reposts in Following</Trans> 111 + </Text> 112 + <Toggle.Switch /> 113 + </View> 114 + </Toggle.Item> 115 + <Divider /> 116 + <Toggle.Item 117 + name="Show Quotes" // no need to translate 118 + label={_(msg`Show quote-posts in Following feed`)} 119 + value={showQuotes} 120 + onChange={() => { 121 + setFeedViewPref({ 122 + hideQuotePosts: showQuotes, 123 + }) 124 + }}> 125 + <View 126 + style={[ 127 + a.flex_row, 128 + a.w_full, 129 + a.py_lg, 130 + a.justify_between, 131 + a.align_center, 132 + ]}> 133 + <Text style={[a.text_md, a.font_bold]}> 134 + <Trans>Show quotes in Following</Trans> 135 + </Text> 136 + <Toggle.Switch /> 137 + </View> 138 + </Toggle.Item> 139 + </View> 140 + 141 + <Description style={[a.mt_lg]}> 142 + <Trans>You can change these settings later.</Trans> 143 + </Description> 144 + 145 + <OnboardingControls.Portal> 146 + <Button 147 + variant="gradient" 148 + color="gradient_sky" 149 + size="large" 150 + label={_(msg`Continue to next step`)} 151 + onPress={onContinue}> 152 + <ButtonText> 153 + <Trans>Continue</Trans> 154 + </ButtonText> 155 + <ButtonIcon icon={ChevronRight} position="right" /> 156 + </Button> 157 + </OnboardingControls.Portal> 158 + </View> 159 + ) 160 + }
+79
src/screens/Onboarding/StepInterests/InterestButton.tsx
··· 1 + import React from 'react' 2 + import {View, ViewStyle, TextStyle} from 'react-native' 3 + 4 + import {useTheme, atoms as a, native} from '#/alf' 5 + import * as Toggle from '#/components/forms/Toggle' 6 + import {Text} from '#/components/Typography' 7 + 8 + import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data' 9 + 10 + export function InterestButton({interest}: {interest: string}) { 11 + const t = useTheme() 12 + const ctx = Toggle.useItemContext() 13 + 14 + const styles = React.useMemo(() => { 15 + const hovered: ViewStyle[] = [ 16 + { 17 + backgroundColor: 18 + t.name === 'light' ? t.palette.contrast_200 : t.palette.contrast_50, 19 + }, 20 + ] 21 + const focused: ViewStyle[] = [] 22 + const pressed: ViewStyle[] = [] 23 + const selected: ViewStyle[] = [ 24 + { 25 + backgroundColor: t.palette.contrast_900, 26 + }, 27 + ] 28 + const selectedHover: ViewStyle[] = [ 29 + { 30 + backgroundColor: t.palette.contrast_800, 31 + }, 32 + ] 33 + const textSelected: TextStyle[] = [ 34 + { 35 + color: t.palette.contrast_100, 36 + }, 37 + ] 38 + 39 + return { 40 + hovered, 41 + focused, 42 + pressed, 43 + selected, 44 + selectedHover, 45 + textSelected, 46 + } 47 + }, [t]) 48 + 49 + return ( 50 + <View 51 + style={[ 52 + { 53 + backgroundColor: t.palette.contrast_100, 54 + paddingVertical: 15, 55 + }, 56 + a.rounded_full, 57 + a.px_2xl, 58 + ctx.hovered ? styles.hovered : {}, 59 + ctx.focused ? styles.hovered : {}, 60 + ctx.pressed ? styles.hovered : {}, 61 + ctx.selected ? styles.selected : {}, 62 + ctx.selected && (ctx.hovered || ctx.focused || ctx.pressed) 63 + ? styles.selectedHover 64 + : {}, 65 + ]}> 66 + <Text 67 + style={[ 68 + { 69 + color: t.palette.contrast_900, 70 + }, 71 + a.font_bold, 72 + native({paddingTop: 2}), 73 + ctx.selected ? styles.textSelected : {}, 74 + ]}> 75 + {INTEREST_TO_DISPLAY_NAME[interest]} 76 + </Text> 77 + </View> 78 + ) 79 + }
+36
src/screens/Onboarding/StepInterests/data.ts
··· 1 + export const INTEREST_TO_DISPLAY_NAME: { 2 + [key: string]: string 3 + } = { 4 + news: 'News', 5 + journalism: 'Journalism', 6 + nature: 'Nature', 7 + art: 'Art', 8 + comics: 'Comics', 9 + writers: 'Writers', 10 + culture: 'Culture', 11 + sports: 'Sports', 12 + pets: 'Pets', 13 + animals: 'Animals', 14 + books: 'Books', 15 + education: 'Education', 16 + climate: 'Climate', 17 + science: 'Science', 18 + politics: 'Politics', 19 + fitness: 'Fitness', 20 + tech: 'Tech', 21 + dev: 'Software Dev', 22 + comedy: 'Comedy', 23 + gaming: 'Video Games', 24 + food: 'Food', 25 + cooking: 'Cooking', 26 + } 27 + 28 + export type ApiResponseMap = { 29 + interests: string[] 30 + suggestedAccountDids: { 31 + [key: string]: string[] 32 + } 33 + suggestedFeedUris: { 34 + [key: string]: string[] 35 + } 36 + }
+260
src/screens/Onboarding/StepInterests/index.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useLingui} from '@lingui/react' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useQuery} from '@tanstack/react-query' 6 + 7 + import {logger} from '#/logger' 8 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 9 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 10 + import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' 11 + import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji' 12 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise' 13 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14 + import {Loader} from '#/components/Loader' 15 + import * as Toggle from '#/components/forms/Toggle' 16 + import {getAgent} from '#/state/session' 17 + import {useAnalytics} from '#/lib/analytics/analytics' 18 + import {Text} from '#/components/Typography' 19 + import {useOnboardingDispatch} from '#/state/shell' 20 + 21 + import {Context} from '#/screens/Onboarding/state' 22 + import { 23 + Title, 24 + Description, 25 + OnboardingControls, 26 + } from '#/screens/Onboarding/Layout' 27 + import { 28 + ApiResponseMap, 29 + INTEREST_TO_DISPLAY_NAME, 30 + } from '#/screens/Onboarding/StepInterests/data' 31 + import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' 32 + import {IconCircle} from '#/screens/Onboarding/IconCircle' 33 + 34 + export function StepInterests() { 35 + const {_} = useLingui() 36 + const t = useTheme() 37 + const {track} = useAnalytics() 38 + const {gtMobile} = useBreakpoints() 39 + const {state, dispatch} = React.useContext(Context) 40 + const [saving, setSaving] = React.useState(false) 41 + const [interests, setInterests] = React.useState<string[]>( 42 + state.interestsStepResults.selectedInterests.map(i => i), 43 + ) 44 + const onboardDispatch = useOnboardingDispatch() 45 + const {isLoading, isError, error, data, refetch, isFetching} = useQuery({ 46 + queryKey: ['interests'], 47 + queryFn: async () => { 48 + try { 49 + const {data} = 50 + await getAgent().app.bsky.unspecced.getTaggedSuggestions() 51 + return data.suggestions.reduce( 52 + (agg, s) => { 53 + const {tag, subject, subjectType} = s 54 + const isDefault = tag === 'default' 55 + 56 + if (!agg.interests.includes(tag) && !isDefault) { 57 + agg.interests.push(tag) 58 + } 59 + 60 + if (subjectType === 'user') { 61 + agg.suggestedAccountDids[tag] = 62 + agg.suggestedAccountDids[tag] || [] 63 + agg.suggestedAccountDids[tag].push(subject) 64 + } 65 + 66 + if (subjectType === 'feed') { 67 + // agg all feeds into defaults 68 + if (isDefault) { 69 + agg.suggestedFeedUris[tag] = agg.suggestedFeedUris[tag] || [] 70 + } else { 71 + agg.suggestedFeedUris[tag] = agg.suggestedFeedUris[tag] || [] 72 + agg.suggestedFeedUris[tag].push(subject) 73 + agg.suggestedFeedUris.default.push(subject) 74 + } 75 + } 76 + 77 + return agg 78 + }, 79 + { 80 + interests: [], 81 + suggestedAccountDids: {}, 82 + suggestedFeedUris: {}, 83 + } as ApiResponseMap, 84 + ) 85 + } catch (e: any) { 86 + logger.info( 87 + `onboarding: getTaggedSuggestions fetch or processing failed`, 88 + ) 89 + logger.error(e) 90 + track('OnboardingV2:StepInterests:Error') 91 + 92 + throw new Error(`a network error occurred`) 93 + } 94 + }, 95 + }) 96 + 97 + const saveInterests = React.useCallback(async () => { 98 + setSaving(true) 99 + 100 + try { 101 + setSaving(false) 102 + dispatch({ 103 + type: 'setInterestsStepResults', 104 + apiResponse: data!, 105 + selectedInterests: interests, 106 + }) 107 + dispatch({type: 'next'}) 108 + 109 + track('OnboardingV2:StepInterests:End', { 110 + selectedInterests: interests, 111 + selectedInterestsLength: interests.length, 112 + }) 113 + } catch (e: any) { 114 + logger.info(`onboading: error saving interests`) 115 + logger.error(e) 116 + } 117 + }, [interests, data, setSaving, dispatch, track]) 118 + 119 + const skipOnboarding = React.useCallback(() => { 120 + onboardDispatch({type: 'finish'}) 121 + dispatch({type: 'finish'}) 122 + track('OnboardingV2:Skip') 123 + }, [onboardDispatch, dispatch, track]) 124 + 125 + React.useEffect(() => { 126 + track('OnboardingV2:Begin') 127 + track('OnboardingV2:StepInterests:Start') 128 + }, [track]) 129 + 130 + const title = isError ? ( 131 + <Trans>Oh no! Something went wrong.</Trans> 132 + ) : ( 133 + <Trans>What are your interests?</Trans> 134 + ) 135 + const description = isError ? ( 136 + <Trans> 137 + We weren't able to connect. Please try again to continue setting up your 138 + account. If it continues to fail, you can skip this flow. 139 + </Trans> 140 + ) : ( 141 + <Trans>We'll use this to help customize your experience.</Trans> 142 + ) 143 + 144 + return ( 145 + <View style={[a.align_start]}> 146 + <IconCircle 147 + icon={isError ? EmojiSad : Hashtag} 148 + style={[ 149 + a.mb_2xl, 150 + isError 151 + ? { 152 + backgroundColor: t.palette.negative_50, 153 + } 154 + : {}, 155 + ]} 156 + iconStyle={[ 157 + isError 158 + ? { 159 + color: t.palette.negative_900, 160 + } 161 + : {}, 162 + ]} 163 + /> 164 + 165 + <Title>{title}</Title> 166 + <Description>{description}</Description> 167 + 168 + <View style={[a.w_full, a.pt_2xl]}> 169 + {isLoading ? ( 170 + <Loader size="xl" /> 171 + ) : isError || !data ? ( 172 + <View 173 + style={[ 174 + a.w_full, 175 + a.p_lg, 176 + a.rounded_md, 177 + { 178 + backgroundColor: t.palette.negative_50, 179 + }, 180 + ]}> 181 + <Text style={[a.text_md]}> 182 + <Text 183 + style={[ 184 + a.text_md, 185 + a.font_bold, 186 + { 187 + color: t.palette.negative_900, 188 + }, 189 + ]}> 190 + Error:{' '} 191 + </Text> 192 + {error?.message || 'an unknown error occurred'} 193 + </Text> 194 + </View> 195 + ) : ( 196 + <Toggle.Group 197 + values={interests} 198 + onChange={setInterests} 199 + label={_(msg`Select your interests from the options below`)}> 200 + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> 201 + {data.interests.map(interest => ( 202 + <Toggle.Item 203 + key={interest} 204 + name={interest} 205 + label={INTEREST_TO_DISPLAY_NAME[interest]}> 206 + <InterestButton interest={interest} /> 207 + </Toggle.Item> 208 + ))} 209 + </View> 210 + </Toggle.Group> 211 + )} 212 + </View> 213 + 214 + <OnboardingControls.Portal> 215 + {isError ? ( 216 + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> 217 + <Button 218 + disabled={isFetching} 219 + variant="solid" 220 + color="secondary" 221 + size="large" 222 + label={_(msg`Retry`)} 223 + onPress={() => refetch()}> 224 + <ButtonText> 225 + <Trans>Retry</Trans> 226 + </ButtonText> 227 + <ButtonIcon icon={ArrowRotateCounterClockwise} position="right" /> 228 + </Button> 229 + <Button 230 + variant="outline" 231 + color="secondary" 232 + size="large" 233 + label={_(msg`Skip this flow`)} 234 + onPress={skipOnboarding}> 235 + <ButtonText> 236 + <Trans>Skip</Trans> 237 + </ButtonText> 238 + </Button> 239 + </View> 240 + ) : ( 241 + <Button 242 + disabled={saving || !data} 243 + variant="gradient" 244 + color="gradient_sky" 245 + size="large" 246 + label={_(msg`Continue to next step`)} 247 + onPress={saveInterests}> 248 + <ButtonText> 249 + <Trans>Continue</Trans> 250 + </ButtonText> 251 + <ButtonIcon 252 + icon={saving ? Loader : ChevronRight} 253 + position="right" 254 + /> 255 + </Button> 256 + )} 257 + </OnboardingControls.Portal> 258 + </View> 259 + ) 260 + }
+135
src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useLingui} from '@lingui/react' 4 + import {msg, Trans} from '@lingui/macro' 5 + 6 + import {isIOS} from '#/platform/detection' 7 + import * as Toast from '#/view/com/util/Toast' 8 + import {atoms as a, useTheme} from '#/alf' 9 + import { 10 + usePreferencesQuery, 11 + usePreferencesSetAdultContentMutation, 12 + } from '#/state/queries/preferences' 13 + import {logger} from '#/logger' 14 + import {Text} from '#/components/Typography' 15 + import {InlineLink} from '#/components/Link' 16 + import * as Toggle from '#/components/forms/Toggle' 17 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 18 + 19 + function Card({children}: React.PropsWithChildren<{}>) { 20 + const t = useTheme() 21 + return ( 22 + <View 23 + style={[ 24 + a.w_full, 25 + a.flex_row, 26 + a.align_center, 27 + a.gap_sm, 28 + a.px_lg, 29 + a.py_md, 30 + a.rounded_sm, 31 + a.mb_md, 32 + t.atoms.bg_contrast_50, 33 + ]}> 34 + {children} 35 + </View> 36 + ) 37 + } 38 + 39 + export function AdultContentEnabledPref() { 40 + const {_} = useLingui() 41 + const t = useTheme() 42 + 43 + // Reuse logic here form ContentFilteringSettings.tsx 44 + const {data: preferences} = usePreferencesQuery() 45 + const {mutate, variables} = usePreferencesSetAdultContentMutation() 46 + 47 + const onToggleAdultContent = React.useCallback(async () => { 48 + if (isIOS) return 49 + 50 + try { 51 + mutate({ 52 + enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), 53 + }) 54 + } catch (e) { 55 + Toast.show( 56 + _(msg`There was an issue syncing your preferences with the server`), 57 + ) 58 + logger.error('Failed to update preferences with server', {error: e}) 59 + } 60 + }, [variables, preferences, mutate, _]) 61 + 62 + if (!preferences) return null 63 + 64 + if (isIOS) { 65 + if (preferences?.adultContentEnabled === true) { 66 + return null 67 + } else { 68 + return ( 69 + <Card> 70 + <CircleInfo size="sm" fill={t.palette.contrast_500} /> 71 + <Text 72 + style={[ 73 + a.flex_1, 74 + t.atoms.text_contrast_700, 75 + a.leading_snug, 76 + {paddingTop: 1}, 77 + ]}> 78 + <Trans> 79 + Adult content can only be enabled via the Web at{' '} 80 + <InlineLink style={[a.leading_snug]} to="https://bsky.app"> 81 + bsky.app 82 + </InlineLink> 83 + . 84 + </Trans> 85 + </Text> 86 + </Card> 87 + ) 88 + } 89 + } else { 90 + if (preferences?.userAge) { 91 + if (preferences.userAge >= 18) { 92 + return ( 93 + <View style={[a.w_full]}> 94 + <Toggle.Item 95 + name={_(msg`Enable adult content in your feeds`)} 96 + label={_(msg`Enable adult content in your feeds`)} 97 + value={variables?.enabled ?? preferences?.adultContentEnabled} 98 + onChange={onToggleAdultContent}> 99 + <View 100 + style={[ 101 + a.flex_row, 102 + a.w_full, 103 + a.justify_between, 104 + a.align_center, 105 + a.py_md, 106 + ]}> 107 + <Text style={[a.font_bold]}>Enable Adult Content</Text> 108 + <Toggle.Switch /> 109 + </View> 110 + </Toggle.Item> 111 + </View> 112 + ) 113 + } else { 114 + return ( 115 + <Card> 116 + <CircleInfo size="sm" fill={t.palette.contrast_500} /> 117 + <Text 118 + style={[ 119 + a.flex_1, 120 + t.atoms.text_contrast_700, 121 + a.leading_snug, 122 + {paddingTop: 1}, 123 + ]}> 124 + <Trans> 125 + You must be 18 years or older to enable adult content 126 + </Trans> 127 + </Text> 128 + </Card> 129 + ) 130 + } 131 + } 132 + 133 + return null 134 + } 135 + }
+85
src/screens/Onboarding/StepModeration/ModerationOption.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {LabelPreference} from '@atproto/api' 4 + import {useLingui} from '@lingui/react' 5 + import {msg} from '@lingui/macro' 6 + 7 + import { 8 + CONFIGURABLE_LABEL_GROUPS, 9 + ConfigurableLabelGroup, 10 + usePreferencesQuery, 11 + usePreferencesSetContentLabelMutation, 12 + } from '#/state/queries/preferences' 13 + import {atoms as a, useTheme} from '#/alf' 14 + import {Text} from '#/components/Typography' 15 + import * as ToggleButton from '#/components/forms/ToggleButton' 16 + 17 + export function ModerationOption({ 18 + labelGroup, 19 + }: { 20 + labelGroup: ConfigurableLabelGroup 21 + }) { 22 + const {_} = useLingui() 23 + const t = useTheme() 24 + const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup] 25 + const {data: preferences} = usePreferencesQuery() 26 + const {mutate, variables} = usePreferencesSetContentLabelMutation() 27 + const visibility = 28 + variables?.visibility ?? preferences?.contentLabels?.[labelGroup] 29 + 30 + const onChange = React.useCallback( 31 + (vis: string[]) => { 32 + mutate({labelGroup, visibility: vis[0] as LabelPreference}) 33 + }, 34 + [mutate, labelGroup], 35 + ) 36 + 37 + const labels = { 38 + hide: _(msg`Hide`), 39 + warn: _(msg`Warn`), 40 + show: _(msg`Show`), 41 + } 42 + 43 + return ( 44 + <View 45 + style={[ 46 + a.flex_row, 47 + a.justify_between, 48 + a.gap_sm, 49 + a.py_xs, 50 + a.px_xs, 51 + a.align_center, 52 + ]}> 53 + <View style={[a.gap_xs, {width: '50%'}]}> 54 + <Text style={[a.font_bold]}>{groupInfo.title}</Text> 55 + <Text style={[t.atoms.text_contrast_700, a.leading_snug]}> 56 + {groupInfo.subtitle} 57 + </Text> 58 + </View> 59 + <View style={[a.justify_center, {minHeight: 35}]}> 60 + {!preferences?.adultContentEnabled && groupInfo.isAdultImagery ? ( 61 + <View style={[a.justify_center, {minHeight: 40}]}> 62 + <Text style={[a.font_bold]}>{labels.hide}</Text> 63 + </View> 64 + ) : ( 65 + <ToggleButton.Group 66 + label={_( 67 + msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`, 68 + )} 69 + values={[visibility ?? 'hide']} 70 + onChange={onChange}> 71 + <ToggleButton.Button name="hide" label={labels.hide}> 72 + {labels.hide} 73 + </ToggleButton.Button> 74 + <ToggleButton.Button name="warn" label={labels.warn}> 75 + {labels.warn} 76 + </ToggleButton.Button> 77 + <ToggleButton.Button name="ignore" label={labels.show}> 78 + {labels.show} 79 + </ToggleButton.Button> 80 + </ToggleButton.Group> 81 + )} 82 + </View> 83 + </View> 84 + ) 85 + }
+91
src/screens/Onboarding/StepModeration/index.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useLingui} from '@lingui/react' 4 + import {msg, Trans} from '@lingui/macro' 5 + 6 + import {atoms as a} from '#/alf' 7 + import {configurableLabelGroups} from 'state/queries/preferences' 8 + import {Divider} from '#/components/Divider' 9 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 10 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 11 + import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 12 + import {usePreferencesQuery} from '#/state/queries/preferences' 13 + import {Loader} from '#/components/Loader' 14 + import {useAnalytics} from '#/lib/analytics/analytics' 15 + 16 + import { 17 + Description, 18 + OnboardingControls, 19 + Title, 20 + } from '#/screens/Onboarding/Layout' 21 + import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption' 22 + import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref' 23 + import {Context} from '#/screens/Onboarding/state' 24 + import {IconCircle} from '#/screens/Onboarding/IconCircle' 25 + 26 + export function StepModeration() { 27 + const {_} = useLingui() 28 + const {track} = useAnalytics() 29 + const {state, dispatch} = React.useContext(Context) 30 + const {data: preferences} = usePreferencesQuery() 31 + 32 + const onContinue = React.useCallback(() => { 33 + dispatch({type: 'next'}) 34 + track('OnboardingV2:StepModeration:End') 35 + }, [track, dispatch]) 36 + 37 + React.useEffect(() => { 38 + track('OnboardingV2:StepModeration:Start') 39 + }, [track]) 40 + 41 + return ( 42 + <View style={[a.align_start]}> 43 + <IconCircle icon={EyeSlash} style={[a.mb_2xl]} /> 44 + 45 + <Title> 46 + <Trans>You are in control</Trans> 47 + </Title> 48 + <Description style={[a.mb_xl]}> 49 + <Trans> 50 + Select the types of content that you want to see (or not see), and 51 + we'll handle the rest. 52 + </Trans> 53 + </Description> 54 + 55 + {!preferences ? ( 56 + <View style={[a.pt_md]}> 57 + <Loader size="xl" /> 58 + </View> 59 + ) : ( 60 + <> 61 + <AdultContentEnabledPref /> 62 + 63 + <View style={[a.gap_sm, a.w_full]}> 64 + {configurableLabelGroups.map((g, index) => ( 65 + <React.Fragment key={index}> 66 + {index === 0 && <Divider />} 67 + <ModerationOption labelGroup={g} /> 68 + <Divider /> 69 + </React.Fragment> 70 + ))} 71 + </View> 72 + </> 73 + )} 74 + 75 + <OnboardingControls.Portal> 76 + <Button 77 + key={state.activeStep} // remove focus state on nav 78 + variant="gradient" 79 + color="gradient_sky" 80 + size="large" 81 + label={_(msg`Continue to next step`)} 82 + onPress={onContinue}> 83 + <ButtonText> 84 + <Trans>Continue</Trans> 85 + </ButtonText> 86 + <ButtonIcon icon={ChevronRight} position="right" /> 87 + </Button> 88 + </OnboardingControls.Portal> 89 + </View> 90 + ) 91 + }
+188
src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
··· 1 + import React from 'react' 2 + import {View, ViewStyle} from 'react-native' 3 + import {AppBskyActorDefs, moderateProfile} from '@atproto/api' 4 + 5 + import {useTheme, atoms as a, flatten} from '#/alf' 6 + import {Text} from '#/components/Typography' 7 + import {useItemContext} from '#/components/forms/Toggle' 8 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 9 + import {UserAvatar} from '#/view/com/util/UserAvatar' 10 + import {useModerationOpts} from '#/state/queries/preferences' 11 + import {RichText} from '#/components/RichText' 12 + 13 + export function SuggestedAccountCard({ 14 + profile, 15 + moderationOpts, 16 + }: { 17 + profile: AppBskyActorDefs.ProfileViewDetailed 18 + moderationOpts: ReturnType<typeof useModerationOpts> 19 + }) { 20 + const t = useTheme() 21 + const ctx = useItemContext() 22 + const moderation = moderateProfile(profile, moderationOpts!) 23 + 24 + const styles = React.useMemo(() => { 25 + const light = t.name === 'light' 26 + const base: ViewStyle[] = [t.atoms.bg_contrast_50] 27 + const hover: ViewStyle[] = [t.atoms.bg_contrast_25] 28 + const selected: ViewStyle[] = [ 29 + { 30 + backgroundColor: light ? t.palette.primary_50 : t.palette.primary_950, 31 + }, 32 + ] 33 + const selectedHover: ViewStyle[] = [ 34 + { 35 + backgroundColor: light ? t.palette.primary_25 : t.palette.primary_975, 36 + }, 37 + ] 38 + const checkboxBase: ViewStyle[] = [t.atoms.bg] 39 + const checkboxSelected: ViewStyle[] = [ 40 + { 41 + backgroundColor: t.palette.primary_500, 42 + }, 43 + ] 44 + const avatarBase: ViewStyle[] = [t.atoms.bg_contrast_100] 45 + const avatarSelected: ViewStyle[] = [ 46 + { 47 + backgroundColor: light ? t.palette.primary_100 : t.palette.primary_900, 48 + }, 49 + ] 50 + 51 + return { 52 + base, 53 + hover: flatten(hover), 54 + selected: flatten(selected), 55 + selectedHover: flatten(selectedHover), 56 + checkboxBase: flatten(checkboxBase), 57 + checkboxSelected: flatten(checkboxSelected), 58 + avatarBase: flatten(avatarBase), 59 + avatarSelected: flatten(avatarSelected), 60 + } 61 + }, [t]) 62 + 63 + return ( 64 + <View 65 + style={[ 66 + a.w_full, 67 + a.p_md, 68 + a.pr_lg, 69 + a.gap_md, 70 + a.rounded_md, 71 + styles.base, 72 + (ctx.hovered || ctx.focused || ctx.pressed) && styles.hover, 73 + ctx.selected && styles.selected, 74 + ctx.selected && 75 + (ctx.hovered || ctx.focused || ctx.pressed) && 76 + styles.selectedHover, 77 + ]}> 78 + <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}> 79 + <View style={[a.flex_row, a.flex_1, a.align_center, a.gap_md]}> 80 + <View 81 + style={[ 82 + {width: 48, height: 48}, 83 + a.relative, 84 + a.rounded_full, 85 + styles.avatarBase, 86 + ctx.selected && styles.avatarSelected, 87 + ]}> 88 + <UserAvatar 89 + size={48} 90 + avatar={profile.avatar} 91 + moderation={moderation.avatar} 92 + /> 93 + </View> 94 + <View style={[a.flex_1]}> 95 + <Text style={[a.font_bold, a.text_md, a.pb_xs]} numberOfLines={1}> 96 + {profile.displayName} 97 + </Text> 98 + <Text style={[t.atoms.text_contrast_600]}>{profile.handle}</Text> 99 + </View> 100 + </View> 101 + 102 + <View 103 + style={[ 104 + a.justify_center, 105 + a.align_center, 106 + a.rounded_sm, 107 + styles.checkboxBase, 108 + ctx.selected && styles.checkboxSelected, 109 + { 110 + width: 28, 111 + height: 28, 112 + }, 113 + ]}> 114 + {ctx.selected && <Check size="sm" fill={t.palette.white} />} 115 + </View> 116 + </View> 117 + 118 + {profile.description && ( 119 + <> 120 + <View 121 + style={[ 122 + { 123 + opacity: ctx.selected ? 0.3 : 1, 124 + borderTopWidth: 1, 125 + }, 126 + a.w_full, 127 + t.name === 'light' ? t.atoms.border : t.atoms.border_contrast, 128 + ctx.selected && { 129 + borderTopColor: t.palette.primary_200, 130 + }, 131 + ]} 132 + /> 133 + 134 + <RichText 135 + value={profile.description} 136 + disableLinks 137 + numberOfLines={2} 138 + /> 139 + </> 140 + )} 141 + </View> 142 + ) 143 + } 144 + 145 + export function SuggestedAccountCardPlaceholder() { 146 + const t = useTheme() 147 + return ( 148 + <View 149 + style={[ 150 + a.w_full, 151 + a.flex_row, 152 + a.justify_between, 153 + a.align_center, 154 + a.p_md, 155 + a.pr_lg, 156 + a.gap_xl, 157 + a.rounded_md, 158 + t.atoms.bg_contrast_25, 159 + ]}> 160 + <View style={[a.flex_row, a.align_center, a.gap_md]}> 161 + <View 162 + style={[ 163 + {width: 48, height: 48}, 164 + a.relative, 165 + a.rounded_full, 166 + t.atoms.bg_contrast_100, 167 + ]} 168 + /> 169 + <View style={[a.gap_xs]}> 170 + <View 171 + style={[ 172 + {width: 100, height: 16}, 173 + a.rounded_sm, 174 + t.atoms.bg_contrast_100, 175 + ]} 176 + /> 177 + <View 178 + style={[ 179 + {width: 60, height: 12}, 180 + a.rounded_sm, 181 + t.atoms.bg_contrast_100, 182 + ]} 183 + /> 184 + </View> 185 + </View> 186 + </View> 187 + ) 188 + }
+198
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyActorDefs} from '@atproto/api' 4 + import {useLingui} from '@lingui/react' 5 + import {msg, Trans} from '@lingui/macro' 6 + 7 + import {atoms as a, useBreakpoints} from '#/alf' 8 + import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 9 + import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' 10 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 11 + import {Text} from '#/components/Typography' 12 + import {useProfilesQuery} from '#/state/queries/profile' 13 + import {Loader} from '#/components/Loader' 14 + import * as Toggle from '#/components/forms/Toggle' 15 + import {useModerationOpts} from '#/state/queries/preferences' 16 + import {useAnalytics} from '#/lib/analytics/analytics' 17 + 18 + import {Context} from '#/screens/Onboarding/state' 19 + import { 20 + Title, 21 + Description, 22 + OnboardingControls, 23 + } from '#/screens/Onboarding/Layout' 24 + import { 25 + SuggestedAccountCard, 26 + SuggestedAccountCardPlaceholder, 27 + } from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard' 28 + import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data' 29 + import {aggregateInterestItems} from '#/screens/Onboarding/util' 30 + import {IconCircle} from '#/screens/Onboarding/IconCircle' 31 + 32 + export function Inner({ 33 + profiles, 34 + onSelect, 35 + moderationOpts, 36 + }: { 37 + profiles: AppBskyActorDefs.ProfileViewDetailed[] 38 + onSelect: (dids: string[]) => void 39 + moderationOpts: ReturnType<typeof useModerationOpts> 40 + }) { 41 + const {_} = useLingui() 42 + const [dids, setDids] = React.useState<string[]>(profiles.map(p => p.did)) 43 + 44 + React.useEffect(() => { 45 + onSelect(dids) 46 + }, [dids, onSelect]) 47 + 48 + return ( 49 + <Toggle.Group 50 + values={dids} 51 + onChange={setDids} 52 + label={_(msg`Select some accounts below to follow`)}> 53 + <View style={[a.gap_md]}> 54 + {profiles.map(profile => ( 55 + <Toggle.Item 56 + key={profile.did} 57 + name={profile.did} 58 + label={_(msg`Follow ${profile.handle}`)}> 59 + <SuggestedAccountCard 60 + profile={profile} 61 + moderationOpts={moderationOpts} 62 + /> 63 + </Toggle.Item> 64 + ))} 65 + </View> 66 + </Toggle.Group> 67 + ) 68 + } 69 + 70 + export function StepSuggestedAccounts() { 71 + const {_} = useLingui() 72 + const {track} = useAnalytics() 73 + const {state, dispatch} = React.useContext(Context) 74 + const {gtMobile} = useBreakpoints() 75 + const suggestedDids = React.useMemo(() => { 76 + return aggregateInterestItems( 77 + state.interestsStepResults.selectedInterests, 78 + state.interestsStepResults.apiResponse.suggestedAccountDids, 79 + state.interestsStepResults.apiResponse.suggestedAccountDids.default, 80 + ) 81 + }, [state.interestsStepResults]) 82 + const moderationOpts = useModerationOpts() 83 + const { 84 + isLoading: isProfilesLoading, 85 + isError, 86 + data, 87 + error, 88 + } = useProfilesQuery({ 89 + handles: suggestedDids, 90 + }) 91 + const [dids, setDids] = React.useState<string[]>([]) 92 + const [saving, setSaving] = React.useState(false) 93 + 94 + const interestsText = React.useMemo(() => { 95 + const i = state.interestsStepResults.selectedInterests.map( 96 + i => INTEREST_TO_DISPLAY_NAME[i], 97 + ) 98 + return i.join(', ') 99 + }, [state.interestsStepResults.selectedInterests]) 100 + 101 + const handleContinue = React.useCallback(async () => { 102 + setSaving(true) 103 + 104 + if (dids.length) { 105 + dispatch({type: 'setSuggestedAccountsStepResults', accountDids: dids}) 106 + } 107 + 108 + setSaving(false) 109 + dispatch({type: 'next'}) 110 + track('OnboardingV2:StepSuggestedAccounts:Start', { 111 + selectedAccountsLength: dids.length, 112 + }) 113 + }, [dids, setSaving, dispatch, track]) 114 + 115 + const handleSkip = React.useCallback(() => { 116 + // if a user comes back and clicks skip, erase follows 117 + dispatch({type: 'setSuggestedAccountsStepResults', accountDids: []}) 118 + dispatch({type: 'next'}) 119 + }, [dispatch]) 120 + 121 + const isLoading = isProfilesLoading && moderationOpts 122 + 123 + React.useEffect(() => { 124 + track('OnboardingV2:StepSuggestedAccounts:Start') 125 + }, [track]) 126 + 127 + return ( 128 + <View style={[a.align_start]}> 129 + <IconCircle icon={At} style={[a.mb_2xl]} /> 130 + 131 + <Title> 132 + <Trans>Here are some accounts for your to follow</Trans> 133 + </Title> 134 + <Description> 135 + {state.interestsStepResults.selectedInterests.length ? ( 136 + <Trans>Based on your interest in {interestsText}</Trans> 137 + ) : ( 138 + <Trans>These are popular accounts you might like.</Trans> 139 + )} 140 + </Description> 141 + 142 + <View style={[a.w_full, a.pt_xl]}> 143 + {isLoading ? ( 144 + <View style={[a.gap_md]}> 145 + {Array(10) 146 + .fill(0) 147 + .map((_, i) => ( 148 + <SuggestedAccountCardPlaceholder key={i} /> 149 + ))} 150 + </View> 151 + ) : isError || !data ? ( 152 + <Text>{error?.toString()}</Text> 153 + ) : ( 154 + <Inner 155 + profiles={data.profiles} 156 + onSelect={setDids} 157 + moderationOpts={moderationOpts} 158 + /> 159 + )} 160 + </View> 161 + 162 + <OnboardingControls.Portal> 163 + <View 164 + style={[ 165 + a.gap_md, 166 + gtMobile ? {flexDirection: 'row-reverse'} : a.flex_col, 167 + ]}> 168 + <Button 169 + disabled={dids.length === 0} 170 + variant="gradient" 171 + color="gradient_sky" 172 + size="large" 173 + label={_( 174 + msg`Follow selected accounts and continue to then next step`, 175 + )} 176 + onPress={handleContinue}> 177 + <ButtonText> 178 + <Trans>Follow All</Trans> 179 + </ButtonText> 180 + <ButtonIcon icon={saving ? Loader : Plus} position="right" /> 181 + </Button> 182 + <Button 183 + variant="solid" 184 + color="secondary" 185 + size="large" 186 + label={_( 187 + msg`Continue to the next step without following any accounts`, 188 + )} 189 + onPress={handleSkip}> 190 + <ButtonText> 191 + <Trans>Skip</Trans> 192 + </ButtonText> 193 + </Button> 194 + </View> 195 + </OnboardingControls.Portal> 196 + </View> 197 + ) 198 + }
+113
src/screens/Onboarding/StepTopicalFeeds.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {useLingui} from '@lingui/react' 4 + import {msg, Trans} from '@lingui/macro' 5 + 6 + import {atoms as a} from '#/alf' 7 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 8 + import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass' 9 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 10 + import * as Toggle from '#/components/forms/Toggle' 11 + import {Loader} from '#/components/Loader' 12 + import {useAnalytics} from '#/lib/analytics/analytics' 13 + 14 + import {Context} from '#/screens/Onboarding/state' 15 + import { 16 + Title, 17 + Description, 18 + OnboardingControls, 19 + } from '#/screens/Onboarding/Layout' 20 + import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' 21 + import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data' 22 + import {aggregateInterestItems} from '#/screens/Onboarding/util' 23 + import {IconCircle} from '#/screens/Onboarding/IconCircle' 24 + 25 + export function StepTopicalFeeds() { 26 + const {_} = useLingui() 27 + const {track} = useAnalytics() 28 + const {state, dispatch} = React.useContext(Context) 29 + const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([]) 30 + const [saving, setSaving] = React.useState(false) 31 + const suggestedFeedUris = React.useMemo(() => { 32 + return aggregateInterestItems( 33 + state.interestsStepResults.selectedInterests, 34 + state.interestsStepResults.apiResponse.suggestedFeedUris, 35 + state.interestsStepResults.apiResponse.suggestedFeedUris.default, 36 + ).slice(0, 10) 37 + }, [state.interestsStepResults]) 38 + 39 + const interestsText = React.useMemo(() => { 40 + const i = state.interestsStepResults.selectedInterests.map( 41 + i => INTEREST_TO_DISPLAY_NAME[i], 42 + ) 43 + return i.join(', ') 44 + }, [state.interestsStepResults.selectedInterests]) 45 + 46 + const saveFeeds = React.useCallback(async () => { 47 + setSaving(true) 48 + 49 + dispatch({type: 'setTopicalFeedsStepResults', feedUris: selectedFeedUris}) 50 + 51 + setSaving(false) 52 + dispatch({type: 'next'}) 53 + track('OnboardingV2:StepTopicalFeeds:End', { 54 + selectedFeeds: selectedFeedUris, 55 + selectedFeedsLength: selectedFeedUris.length, 56 + }) 57 + }, [selectedFeedUris, dispatch, track]) 58 + 59 + React.useEffect(() => { 60 + track('OnboardingV2:StepTopicalFeeds:Start') 61 + }, [track]) 62 + 63 + return ( 64 + <View style={[a.align_start]}> 65 + <IconCircle icon={ListMagnifyingGlass} style={[a.mb_2xl]} /> 66 + 67 + <Title> 68 + <Trans>Feeds can be topical as well!</Trans> 69 + </Title> 70 + <Description> 71 + {state.interestsStepResults.selectedInterests.length ? ( 72 + <Trans> 73 + Here are some topical feeds based on your interests: {interestsText} 74 + . You can choose to follow as many as you like. 75 + </Trans> 76 + ) : ( 77 + <Trans> 78 + Here are some popular topical feeds. You can choose to follow as 79 + many as you like. 80 + </Trans> 81 + )} 82 + </Description> 83 + 84 + <View style={[a.w_full, a.pb_2xl, a.pt_2xl]}> 85 + <Toggle.Group 86 + values={selectedFeedUris} 87 + onChange={setSelectedFeedUris} 88 + label={_(msg`Select topical feeds to follow from the list below`)}> 89 + <View style={[a.gap_md]}> 90 + {suggestedFeedUris.map(uri => ( 91 + <FeedCard key={uri} config={{default: false, uri}} /> 92 + ))} 93 + </View> 94 + </Toggle.Group> 95 + </View> 96 + 97 + <OnboardingControls.Portal> 98 + <Button 99 + key={state.activeStep} // remove focus state on nav 100 + variant="gradient" 101 + color="gradient_sky" 102 + size="large" 103 + label={_(msg`Continue to next step`)} 104 + onPress={saveFeeds}> 105 + <ButtonText> 106 + <Trans>Continue</Trans> 107 + </ButtonText> 108 + <ButtonIcon icon={saving ? Loader : ChevronRight} position="right" /> 109 + </Button> 110 + </OnboardingControls.Portal> 111 + </View> 112 + ) 113 + }
+38
src/screens/Onboarding/index.tsx
··· 1 + import React from 'react' 2 + 3 + import {Portal} from '#/components/Portal' 4 + 5 + import {Context, initialState, reducer} from '#/screens/Onboarding/state' 6 + import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout' 7 + import {StepInterests} from '#/screens/Onboarding/StepInterests' 8 + import {StepSuggestedAccounts} from '#/screens/Onboarding/StepSuggestedAccounts' 9 + import {StepFollowingFeed} from '#/screens/Onboarding/StepFollowingFeed' 10 + import {StepAlgoFeeds} from '#/screens/Onboarding/StepAlgoFeeds' 11 + import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds' 12 + import {StepFinished} from '#/screens/Onboarding/StepFinished' 13 + import {StepModeration} from '#/screens/Onboarding/StepModeration' 14 + 15 + export function Onboarding() { 16 + const [state, dispatch] = React.useReducer(reducer, {...initialState}) 17 + 18 + return ( 19 + <Portal> 20 + <OnboardingControls.Provider> 21 + <Context.Provider 22 + value={React.useMemo(() => ({state, dispatch}), [state, dispatch])}> 23 + <Layout> 24 + {state.activeStep === 'interests' && <StepInterests />} 25 + {state.activeStep === 'suggestedAccounts' && ( 26 + <StepSuggestedAccounts /> 27 + )} 28 + {state.activeStep === 'followingFeed' && <StepFollowingFeed />} 29 + {state.activeStep === 'algoFeeds' && <StepAlgoFeeds />} 30 + {state.activeStep === 'topicalFeeds' && <StepTopicalFeeds />} 31 + {state.activeStep === 'moderation' && <StepModeration />} 32 + {state.activeStep === 'finished' && <StepFinished />} 33 + </Layout> 34 + </Context.Provider> 35 + </OnboardingControls.Provider> 36 + </Portal> 37 + ) 38 + }
+201
src/screens/Onboarding/state.ts
··· 1 + import React from 'react' 2 + 3 + import {ApiResponseMap} from '#/screens/Onboarding/StepInterests/data' 4 + import {logger} from '#/logger' 5 + 6 + export type OnboardingState = { 7 + hasPrev: boolean 8 + totalSteps: number 9 + activeStep: 10 + | 'interests' 11 + | 'suggestedAccounts' 12 + | 'followingFeed' 13 + | 'algoFeeds' 14 + | 'topicalFeeds' 15 + | 'moderation' 16 + | 'finished' 17 + activeStepIndex: number 18 + 19 + interestsStepResults: { 20 + selectedInterests: string[] 21 + apiResponse: ApiResponseMap 22 + } 23 + suggestedAccountsStepResults: { 24 + accountDids: string[] 25 + } 26 + algoFeedsStepResults: { 27 + feedUris: string[] 28 + } 29 + topicalFeedsStepResults: { 30 + feedUris: string[] 31 + } 32 + } 33 + 34 + export type OnboardingAction = 35 + | { 36 + type: 'next' 37 + } 38 + | { 39 + type: 'prev' 40 + } 41 + | { 42 + type: 'finish' 43 + } 44 + | { 45 + type: 'setInterestsStepResults' 46 + selectedInterests: string[] 47 + apiResponse: ApiResponseMap 48 + } 49 + | { 50 + type: 'setSuggestedAccountsStepResults' 51 + accountDids: string[] 52 + } 53 + | { 54 + type: 'setAlgoFeedsStepResults' 55 + feedUris: string[] 56 + } 57 + | { 58 + type: 'setTopicalFeedsStepResults' 59 + feedUris: string[] 60 + } 61 + 62 + export const initialState: OnboardingState = { 63 + hasPrev: false, 64 + totalSteps: 7, 65 + activeStep: 'interests', 66 + activeStepIndex: 1, 67 + 68 + interestsStepResults: { 69 + selectedInterests: [], 70 + apiResponse: { 71 + interests: [], 72 + suggestedAccountDids: {}, 73 + suggestedFeedUris: {}, 74 + }, 75 + }, 76 + suggestedAccountsStepResults: { 77 + accountDids: [], 78 + }, 79 + algoFeedsStepResults: { 80 + feedUris: [], 81 + }, 82 + topicalFeedsStepResults: { 83 + feedUris: [], 84 + }, 85 + } 86 + 87 + export const Context = React.createContext<{ 88 + state: OnboardingState 89 + dispatch: React.Dispatch<OnboardingAction> 90 + }>({ 91 + state: {...initialState}, 92 + dispatch: () => {}, 93 + }) 94 + 95 + export function reducer( 96 + s: OnboardingState, 97 + a: OnboardingAction, 98 + ): OnboardingState { 99 + let next = {...s} 100 + 101 + switch (a.type) { 102 + case 'next': { 103 + if (s.activeStep === 'interests') { 104 + next.activeStep = 'suggestedAccounts' 105 + next.activeStepIndex = 2 106 + } else if (s.activeStep === 'suggestedAccounts') { 107 + next.activeStep = 'followingFeed' 108 + next.activeStepIndex = 3 109 + } else if (s.activeStep === 'followingFeed') { 110 + next.activeStep = 'algoFeeds' 111 + next.activeStepIndex = 4 112 + } else if (s.activeStep === 'algoFeeds') { 113 + next.activeStep = 'topicalFeeds' 114 + next.activeStepIndex = 5 115 + } else if (s.activeStep === 'topicalFeeds') { 116 + next.activeStep = 'moderation' 117 + next.activeStepIndex = 6 118 + } else if (s.activeStep === 'moderation') { 119 + next.activeStep = 'finished' 120 + next.activeStepIndex = 7 121 + } 122 + break 123 + } 124 + case 'prev': { 125 + if (s.activeStep === 'suggestedAccounts') { 126 + next.activeStep = 'interests' 127 + next.activeStepIndex = 1 128 + } else if (s.activeStep === 'followingFeed') { 129 + next.activeStep = 'suggestedAccounts' 130 + next.activeStepIndex = 2 131 + } else if (s.activeStep === 'algoFeeds') { 132 + next.activeStep = 'followingFeed' 133 + next.activeStepIndex = 3 134 + } else if (s.activeStep === 'topicalFeeds') { 135 + next.activeStep = 'algoFeeds' 136 + next.activeStepIndex = 4 137 + } else if (s.activeStep === 'moderation') { 138 + next.activeStep = 'topicalFeeds' 139 + next.activeStepIndex = 5 140 + } else if (s.activeStep === 'finished') { 141 + next.activeStep = 'moderation' 142 + next.activeStepIndex = 6 143 + } 144 + break 145 + } 146 + case 'finish': { 147 + next = initialState 148 + break 149 + } 150 + case 'setInterestsStepResults': { 151 + next.interestsStepResults = { 152 + selectedInterests: a.selectedInterests, 153 + apiResponse: a.apiResponse, 154 + } 155 + break 156 + } 157 + case 'setSuggestedAccountsStepResults': { 158 + next.suggestedAccountsStepResults = { 159 + accountDids: next.suggestedAccountsStepResults.accountDids.concat( 160 + a.accountDids, 161 + ), 162 + } 163 + break 164 + } 165 + case 'setAlgoFeedsStepResults': { 166 + next.algoFeedsStepResults = { 167 + feedUris: a.feedUris, 168 + } 169 + break 170 + } 171 + case 'setTopicalFeedsStepResults': { 172 + next.topicalFeedsStepResults = { 173 + feedUris: next.topicalFeedsStepResults.feedUris.concat(a.feedUris), 174 + } 175 + break 176 + } 177 + } 178 + 179 + const state = { 180 + ...next, 181 + hasPrev: next.activeStep !== 'interests', 182 + } 183 + 184 + logger.debug(`onboarding`, { 185 + hasPrev: state.hasPrev, 186 + activeStep: state.activeStep, 187 + activeStepIndex: state.activeStepIndex, 188 + interestsStepResults: { 189 + selectedInterests: state.interestsStepResults.selectedInterests, 190 + }, 191 + suggestedAccountsStepResults: state.suggestedAccountsStepResults, 192 + algoFeedsStepResults: state.algoFeedsStepResults, 193 + topicalFeedsStepResults: state.topicalFeedsStepResults, 194 + }) 195 + 196 + if (s.activeStep !== state.activeStep) { 197 + logger.info(`onboarding: step changed`, {activeStep: state.activeStep}) 198 + } 199 + 200 + return state 201 + }
+112
src/screens/Onboarding/util.ts
··· 1 + import {AppBskyGraphFollow, AppBskyGraphGetFollows} from '@atproto/api' 2 + 3 + import {until} from '#/lib/async/until' 4 + import {getAgent} from '#/state/session' 5 + 6 + function shuffle(array: any) { 7 + let currentIndex = array.length, 8 + randomIndex 9 + 10 + // While there remain elements to shuffle. 11 + while (currentIndex > 0) { 12 + // Pick a remaining element. 13 + randomIndex = Math.floor(Math.random() * currentIndex) 14 + currentIndex-- 15 + 16 + // And swap it with the current element. 17 + ;[array[currentIndex], array[randomIndex]] = [ 18 + array[randomIndex], 19 + array[currentIndex], 20 + ] 21 + } 22 + 23 + return array 24 + } 25 + 26 + export function aggregateInterestItems( 27 + interests: string[], 28 + map: {[key: string]: string[]}, 29 + fallbackItems: string[], 30 + ) { 31 + const selected = interests.length 32 + const all = interests 33 + .map(i => { 34 + const suggestions = shuffle(map[i]) 35 + 36 + if (selected === 1) { 37 + return suggestions // return all 38 + } else if (selected === 2) { 39 + return suggestions.slice(0, 5) // return 5 40 + } else { 41 + return suggestions.slice(0, 3) // return 3 42 + } 43 + }) 44 + .flat() 45 + // dedupe suggestions 46 + const results = Array.from(new Set(all)) 47 + 48 + // backfill 49 + if (results.length < 20) { 50 + results.push(...shuffle(fallbackItems)) 51 + } 52 + 53 + // dedupe and return 20 54 + return Array.from(new Set(results)).slice(0, 20) 55 + } 56 + 57 + export async function bulkWriteFollows(dids: string[]) { 58 + const session = getAgent().session 59 + 60 + if (!session) { 61 + throw new Error(`bulkWriteFollows failed: no session`) 62 + } 63 + 64 + const followRecords: AppBskyGraphFollow.Record[] = dids.map(did => { 65 + return { 66 + $type: 'app.bsky.graph.follow', 67 + subject: did, 68 + createdAt: new Date().toISOString(), 69 + } 70 + }) 71 + const followWrites = followRecords.map(r => ({ 72 + $type: 'com.atproto.repo.applyWrites#create', 73 + collection: 'app.bsky.graph.follow', 74 + value: r, 75 + })) 76 + 77 + await getAgent().com.atproto.repo.applyWrites({ 78 + repo: session.did, 79 + writes: followWrites, 80 + }) 81 + await whenFollowsIndexed(session.did, res => !!res.data.follows.length) 82 + } 83 + 84 + async function whenFollowsIndexed( 85 + actor: string, 86 + fn: (res: AppBskyGraphGetFollows.Response) => boolean, 87 + ) { 88 + await until( 89 + 5, // 5 tries 90 + 1e3, // 1s delay between tries 91 + fn, 92 + () => 93 + getAgent().app.bsky.graph.getFollows({ 94 + actor, 95 + limit: 1, 96 + }), 97 + ) 98 + } 99 + 100 + /** 101 + * Kinda hacky, but we want For Your or Discover to appear as the first pinned 102 + * feed after Following 103 + */ 104 + export function sortPrimaryAlgorithmFeeds(uris: string[]) { 105 + return uris.sort(uri => { 106 + return uri.includes('the-algorithm') 107 + ? -1 108 + : uri.includes('whats-hot') 109 + ? 0 110 + : 1 111 + }) 112 + }
+1
src/state/queries/preferences/const.ts
··· 48 48 feedViewPrefs: DEFAULT_HOME_FEED_PREFS, 49 49 threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, 50 50 userAge: 13, // TODO(pwi) 51 + interests: {tags: []}, 51 52 }
+11 -8
src/state/queries/preferences/types.ts
··· 5 5 BskyFeedViewPreference, 6 6 } from '@atproto/api' 7 7 8 - export type ConfigurableLabelGroup = 9 - | 'nsfw' 10 - | 'nudity' 11 - | 'suggestive' 12 - | 'gore' 13 - | 'hate' 14 - | 'spam' 15 - | 'impersonation' 8 + export const configurableLabelGroups = [ 9 + 'nsfw', 10 + 'nudity', 11 + 'suggestive', 12 + 'gore', 13 + 'hate', 14 + 'spam', 15 + 'impersonation', 16 + ] as const 17 + export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number] 18 + 16 19 export type LabelGroup = 17 20 | ConfigurableLabelGroup 18 21 | 'illegal'
+12
src/state/queries/profile.ts
··· 24 24 import {track} from '#/lib/analytics/analytics' 25 25 26 26 export const RQKEY = (did: string) => ['profile', did] 27 + export const profilesQueryKey = (handles: string[]) => ['profiles', handles] 27 28 28 29 export function useProfileQuery({did}: {did: string | undefined}) { 29 30 const {currentAccount} = useSession() ··· 42 43 return res.data 43 44 }, 44 45 enabled: !!did, 46 + }) 47 + } 48 + 49 + export function useProfilesQuery({handles}: {handles: string[]}) { 50 + return useQuery({ 51 + staleTime: STALE.MINUTES.FIVE, 52 + queryKey: profilesQueryKey(handles), 53 + queryFn: async () => { 54 + const res = await getAgent().getProfiles({actors: handles}) 55 + return res.data 56 + }, 45 57 }) 46 58 } 47 59
+85 -4
src/view/screens/Storybook/Buttons.tsx
··· 11 11 } from '#/components/Button' 12 12 import {H1} from '#/components/Typography' 13 13 import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight' 14 + import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' 14 15 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' 15 16 16 17 export function Buttons() { ··· 91 92 )} 92 93 </View> 93 94 </View> 95 + </View> 94 96 97 + <View style={[a.flex_wrap, a.gap_md, a.align_start]}> 95 98 <Button 96 99 variant="gradient" 97 100 color="gradient_sky" 98 101 size="large" 99 102 label="Link out"> 100 103 <ButtonText>Link out</ButtonText> 101 - <ButtonIcon icon={ArrowTopRight} /> 104 + <ButtonIcon icon={ArrowTopRight} position="right" /> 102 105 </Button> 103 106 104 107 <Button ··· 107 110 size="small" 108 111 label="Link out"> 109 112 <ButtonText>Link out</ButtonText> 110 - <ButtonIcon icon={ArrowTopRight} /> 113 + <ButtonIcon icon={ArrowTopRight} position="right" /> 111 114 </Button> 112 115 113 116 <Button ··· 115 118 color="gradient_sky" 116 119 size="small" 117 120 label="Link out"> 118 - <ButtonIcon icon={Globe} /> 119 - <ButtonText>See the world</ButtonText> 121 + <ButtonText>Link xxxxxx</ButtonText> 122 + </Button> 123 + 124 + <Button 125 + variant="gradient" 126 + color="gradient_sky" 127 + size="small" 128 + label="Link out"> 129 + <ButtonIcon icon={Globe} position="left" /> 130 + <ButtonText>Link out</ButtonText> 131 + </Button> 132 + </View> 133 + 134 + <View style={[a.flex_row, a.gap_md, a.align_start]}> 135 + <Button 136 + variant="solid" 137 + color="primary" 138 + size="large" 139 + shape="round" 140 + label="Link out"> 141 + <ButtonIcon icon={ChevronLeft} /> 142 + </Button> 143 + <Button 144 + variant="gradient" 145 + color="gradient_sunset" 146 + size="small" 147 + shape="round" 148 + label="Link out"> 149 + <ButtonIcon icon={ChevronLeft} /> 150 + </Button> 151 + <Button 152 + variant="outline" 153 + color="primary" 154 + size="large" 155 + shape="round" 156 + label="Link out"> 157 + <ButtonIcon icon={ChevronLeft} /> 158 + </Button> 159 + <Button 160 + variant="ghost" 161 + color="primary" 162 + size="small" 163 + shape="round" 164 + label="Link out"> 165 + <ButtonIcon icon={ChevronLeft} /> 166 + </Button> 167 + </View> 168 + 169 + <View style={[a.flex_row, a.gap_md, a.align_start]}> 170 + <Button 171 + variant="solid" 172 + color="primary" 173 + size="large" 174 + shape="square" 175 + label="Link out"> 176 + <ButtonIcon icon={ChevronLeft} /> 177 + </Button> 178 + <Button 179 + variant="gradient" 180 + color="gradient_sunset" 181 + size="small" 182 + shape="square" 183 + label="Link out"> 184 + <ButtonIcon icon={ChevronLeft} /> 185 + </Button> 186 + <Button 187 + variant="outline" 188 + color="primary" 189 + size="large" 190 + shape="square" 191 + label="Link out"> 192 + <ButtonIcon icon={ChevronLeft} /> 193 + </Button> 194 + <Button 195 + variant="ghost" 196 + color="primary" 197 + size="small" 198 + shape="square" 199 + label="Link out"> 200 + <ButtonIcon icon={ChevronLeft} /> 120 201 </Button> 121 202 </View> 122 203 </View>
+17
src/view/screens/Storybook/Forms.tsx
··· 209 209 Show 210 210 </ToggleButton.Button> 211 211 </ToggleButton.Group> 212 + 213 + <View> 214 + <ToggleButton.Group 215 + label="Preferences" 216 + values={toggleGroupDValues} 217 + onChange={setToggleGroupDValues}> 218 + <ToggleButton.Button name="hide" label="Hide"> 219 + Hide 220 + </ToggleButton.Button> 221 + <ToggleButton.Button name="warn" label="Warn"> 222 + Warn 223 + </ToggleButton.Button> 224 + <ToggleButton.Button name="show" label="Show"> 225 + Show 226 + </ToggleButton.Button> 227 + </ToggleButton.Group> 228 + </View> 212 229 </View> 213 230 </View> 214 231 )
+35 -11
src/view/screens/Storybook/Links.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 4 - import {atoms as a} from '#/alf' 4 + import {useTheme, atoms as a} from '#/alf' 5 5 import {ButtonText} from '#/components/Button' 6 - import {Link} from '#/components/Link' 7 - import {H1, H3} from '#/components/Typography' 6 + import {InlineLink, Link} from '#/components/Link' 7 + import {H1, H3, Text} from '#/components/Typography' 8 8 9 9 export function Links() { 10 + const t = useTheme() 10 11 return ( 11 12 <View style={[a.gap_md, a.align_start]}> 12 13 <H1>Links</H1> 13 14 14 15 <View style={[a.gap_md, a.align_start]}> 15 - <Link 16 + <InlineLink 16 17 to="https://blueskyweb.xyz" 17 18 warnOnMismatchingTextChild 18 19 style={[a.text_md]}> 19 20 External 20 - </Link> 21 - <Link to="https://blueskyweb.xyz" style={[a.text_md]}> 21 + </InlineLink> 22 + <InlineLink to="https://blueskyweb.xyz" style={[a.text_md]}> 22 23 <H3>External with custom children</H3> 23 - </Link> 24 - <Link 24 + </InlineLink> 25 + <InlineLink 25 26 to="https://blueskyweb.xyz" 26 27 warnOnMismatchingTextChild 27 28 style={[a.text_lg]}> 28 29 https://blueskyweb.xyz 29 - </Link> 30 - <Link 30 + </InlineLink> 31 + <InlineLink 31 32 to="https://bsky.app/profile/bsky.app" 32 33 warnOnMismatchingTextChild 33 34 style={[a.text_md]}> 34 35 Internal 35 - </Link> 36 + </InlineLink> 36 37 37 38 <Link 38 39 variant="solid" ··· 41 42 label="View @bsky.app's profile" 42 43 to="https://bsky.app/profile/bsky.app"> 43 44 <ButtonText>Link as a button</ButtonText> 45 + </Link> 46 + 47 + <Link 48 + label="View @bsky.app's profile" 49 + to="https://bsky.app/profile/bsky.app"> 50 + <View 51 + style={[ 52 + a.flex_row, 53 + a.align_center, 54 + a.gap_md, 55 + a.rounded_md, 56 + a.p_md, 57 + t.atoms.bg_contrast_25, 58 + ]}> 59 + <View 60 + style={[ 61 + {width: 32, height: 32}, 62 + a.rounded_full, 63 + t.atoms.bg_contrast_200, 64 + ]} 65 + /> 66 + <Text>View @bsky.app's profile</Text> 67 + </View> 44 68 </Link> 45 69 </View> 46 70 </View>
+11
src/view/screens/Storybook/Typography.tsx
··· 3 3 4 4 import {atoms as a} from '#/alf' 5 5 import {Text, H1, H2, H3, H4, H5, H6, P} from '#/components/Typography' 6 + import {RichText} from '#/components/RichText' 6 7 7 8 export function Typography() { 8 9 return ( ··· 25 26 <Text style={[a.text_sm]}>atoms.text_sm</Text> 26 27 <Text style={[a.text_xs]}>atoms.text_xs</Text> 27 28 <Text style={[a.text_2xs]}>atoms.text_2xs</Text> 29 + 30 + <RichText 31 + resolveFacets 32 + value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`} 33 + /> 34 + <RichText 35 + resolveFacets 36 + value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`} 37 + style={[a.text_xl]} 38 + /> 28 39 </View> 29 40 ) 30 41 }
+7 -2
src/view/shell/createNativeStackNavigatorWithAuth.tsx
··· 1 1 import * as React from 'react' 2 2 import {View} from 'react-native' 3 - import {PWI_ENABLED} from '#/lib/build-flags' 3 + import {PWI_ENABLED, NEW_ONBOARDING_ENABLED} from '#/lib/build-flags' 4 4 5 5 // Based on @react-navigation/native-stack/src/createNativeStackNavigator.ts 6 6 // MIT License ··· 38 38 import {Deactivated} from '#/screens/Deactivated' 39 39 import {LoggedOut} from '../com/auth/LoggedOut' 40 40 import {Onboarding} from '../com/auth/Onboarding' 41 + import {Onboarding as NewOnboarding} from '#/screens/Onboarding' 41 42 42 43 type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & { 43 44 requireAuth?: boolean ··· 111 112 return <LoggedOut onDismiss={() => setShowLoggedOut(false)} /> 112 113 } 113 114 if (onboardingState.isActive) { 114 - return <Onboarding /> 115 + if (NEW_ONBOARDING_ENABLED) { 116 + return <NewOnboarding /> 117 + } else { 118 + return <Onboarding /> 119 + } 115 120 } 116 121 const newDescriptors: typeof descriptors = {} 117 122 for (let key in descriptors) {
+25 -22
web/index.html
··· 40 40 /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */ 41 41 -webkit-text-size-adjust: 100%; 42 42 height: calc(100% + env(safe-area-inset-top)); 43 - scrollbar-gutter: stable; 44 - } 45 - 46 - /* Remove autofill styles on Webkit */ 47 - input:-webkit-autofill, 48 - input:-webkit-autofill:hover, 49 - input:-webkit-autofill:focus, 50 - textarea:-webkit-autofill, 51 - textarea:-webkit-autofill:hover, 52 - textarea:-webkit-autofill:focus, 53 - select:-webkit-autofill, 54 - select:-webkit-autofill:hover, 55 - select:-webkit-autofill:focus { 56 - border: 0; 57 - -webkit-text-fill-color: transparent; 58 - -webkit-box-shadow: none; 59 - } 60 - /* Force left-align date/time inputs on iOS mobile */ 61 - input::-webkit-date-and-time-value { 62 - text-align: left; 43 + scrollbar-gutter: stable both-edges; 63 44 } 64 45 65 46 /* Color theming */ ··· 71 52 html.colorMode--dark { 72 53 --text: white; 73 54 --background: hsl(211, 20%, 4%); 74 - --backgroundLight: hsl(211, 20%, 10%); 55 + --backgroundLight: hsl(211, 20%, 20%); 75 56 color-scheme: dark; 76 57 } 77 58 @media (prefers-color-scheme: light) { ··· 85 66 html.colorMode--system { 86 67 --text: white; 87 68 --background: hsl(211, 20%, 4%); 88 - --backgroundLight: hsl(211, 20%, 10%); 69 + --backgroundLight: hsl(211, 20%, 20%); 89 70 color-scheme: dark; 90 71 } 72 + } 73 + 74 + ::selection { 75 + background-color: var(--backgroundLight); 76 + } 77 + 78 + /* Remove autofill styles on Webkit */ 79 + input:autofill, 80 + input:-webkit-autofill, 81 + input:-webkit-autofill:hover, 82 + input:-webkit-autofill:focus, 83 + input:-webkit-autofill:active{ 84 + -webkit-background-clip: text; 85 + -webkit-text-fill-color: var(--text); 86 + transition: background-color 5000s ease-in-out 0s; 87 + box-shadow: inset 0 0 20px 20px var(--background); 88 + background: var(--background); 89 + color: var(--text); 90 + } 91 + /* Force left-align date/time inputs on iOS mobile */ 92 + input::-webkit-date-and-time-value { 93 + text-align: left; 91 94 } 92 95 93 96 body {