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>
| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 391 B  | 
							
								
								
									
										1
									
								
								assets/icons/at_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 756 B  | 
							
								
								
									
										1
									
								
								assets/icons/check_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 283 B  | 
							
								
								
									
										1
									
								
								assets/icons/chevronLeft_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 263 B  | 
							
								
								
									
										1
									
								
								assets/icons/chevronRight_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 263 B  | 
							
								
								
									
										1
									
								
								assets/icons/circleInfo_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 351 B  | 
							
								
								
									
										1
									
								
								assets/icons/emojiSad_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 607 B  | 
							
								
								
									
										1
									
								
								assets/icons/eyeSlash_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 825 B  | 
							
								
								
									
										1
									
								
								assets/icons/filterTimeline_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 411 B  | 
							
								
								
									
										1
									
								
								assets/icons/growth_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 349 B  | 
							
								
								
									
										1
									
								
								assets/icons/hashtag_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 489 B  | 
| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 452 B  | 
							
								
								
									
										1
									
								
								assets/icons/listSparkle_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 629 B  | 
							
								
								
									
										1
									
								
								assets/icons/loader_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 246 B  | 
							
								
								
									
										1
									
								
								assets/icons/news2_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 517 B  | 
							
								
								
									
										1
									
								
								assets/icons/plusLarge_stroke2_corner0_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 237 B  | 
							
								
								
									
										1
									
								
								assets/icons/trending2_stroke2_corner2_rounded.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 447 B  | 
| 
						 | 
				
			
			@ -39,25 +39,6 @@
 | 
			
		|||
      scrollbar-gutter: stable both-edges;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* Remove autofill styles on Webkit */
 | 
			
		||||
    input:-webkit-autofill,
 | 
			
		||||
    input:-webkit-autofill:hover, 
 | 
			
		||||
    input:-webkit-autofill:focus,
 | 
			
		||||
    textarea:-webkit-autofill,
 | 
			
		||||
    textarea:-webkit-autofill:hover,
 | 
			
		||||
    textarea:-webkit-autofill:focus,
 | 
			
		||||
    select:-webkit-autofill,
 | 
			
		||||
    select:-webkit-autofill:hover,
 | 
			
		||||
    select:-webkit-autofill:focus {
 | 
			
		||||
      border: 0;
 | 
			
		||||
      -webkit-text-fill-color: transparent;
 | 
			
		||||
      -webkit-box-shadow: none;
 | 
			
		||||
    }
 | 
			
		||||
    /* Force left-align date/time inputs on iOS mobile */
 | 
			
		||||
    input::-webkit-date-and-time-value {
 | 
			
		||||
      text-align: left;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* Color theming */
 | 
			
		||||
    :root {
 | 
			
		||||
      --text: black;
 | 
			
		||||
| 
						 | 
				
			
			@ -86,6 +67,28 @@
 | 
			
		|||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    ::selection {
 | 
			
		||||
      background-color: var(--backgroundLight);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /* Remove autofill styles on Webkit */
 | 
			
		||||
    input:autofill,
 | 
			
		||||
    input:-webkit-autofill,
 | 
			
		||||
    input:-webkit-autofill:hover,
 | 
			
		||||
    input:-webkit-autofill:focus,
 | 
			
		||||
    input:-webkit-autofill:active{
 | 
			
		||||
        -webkit-background-clip: text;
 | 
			
		||||
        -webkit-text-fill-color: var(--text);
 | 
			
		||||
        transition: background-color 5000s ease-in-out 0s;
 | 
			
		||||
        box-shadow: inset 0 0 20px 20px var(--background);
 | 
			
		||||
        background: var(--background);
 | 
			
		||||
        color: var(--text);
 | 
			
		||||
    }
 | 
			
		||||
    /* Force left-align date/time inputs on iOS mobile */
 | 
			
		||||
    input::-webkit-date-and-time-value {
 | 
			
		||||
      text-align: left;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    body {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      /* Allows you to scroll below the viewport; default value is visible */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -104,6 +104,9 @@ export const atoms = {
 | 
			
		|||
  flex: {
 | 
			
		||||
    display: 'flex',
 | 
			
		||||
  },
 | 
			
		||||
  flex_col: {
 | 
			
		||||
    flexDirection: 'column',
 | 
			
		||||
  },
 | 
			
		||||
  flex_row: {
 | 
			
		||||
    flexDirection: 'row',
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			@ -149,45 +152,38 @@ export const atoms = {
 | 
			
		|||
  },
 | 
			
		||||
  text_2xs: {
 | 
			
		||||
    fontSize: tokens.fontSize._2xs,
 | 
			
		||||
    lineHeight: tokens.fontSize._2xs,
 | 
			
		||||
  },
 | 
			
		||||
  text_xs: {
 | 
			
		||||
    fontSize: tokens.fontSize.xs,
 | 
			
		||||
    lineHeight: tokens.fontSize.xs,
 | 
			
		||||
  },
 | 
			
		||||
  text_sm: {
 | 
			
		||||
    fontSize: tokens.fontSize.sm,
 | 
			
		||||
    lineHeight: tokens.fontSize.sm,
 | 
			
		||||
  },
 | 
			
		||||
  text_md: {
 | 
			
		||||
    fontSize: tokens.fontSize.md,
 | 
			
		||||
    lineHeight: tokens.fontSize.md,
 | 
			
		||||
  },
 | 
			
		||||
  text_lg: {
 | 
			
		||||
    fontSize: tokens.fontSize.lg,
 | 
			
		||||
    lineHeight: tokens.fontSize.lg,
 | 
			
		||||
  },
 | 
			
		||||
  text_xl: {
 | 
			
		||||
    fontSize: tokens.fontSize.xl,
 | 
			
		||||
    lineHeight: tokens.fontSize.xl,
 | 
			
		||||
  },
 | 
			
		||||
  text_2xl: {
 | 
			
		||||
    fontSize: tokens.fontSize._2xl,
 | 
			
		||||
    lineHeight: tokens.fontSize._2xl,
 | 
			
		||||
  },
 | 
			
		||||
  text_3xl: {
 | 
			
		||||
    fontSize: tokens.fontSize._3xl,
 | 
			
		||||
    lineHeight: tokens.fontSize._3xl,
 | 
			
		||||
  },
 | 
			
		||||
  text_4xl: {
 | 
			
		||||
    fontSize: tokens.fontSize._4xl,
 | 
			
		||||
    lineHeight: tokens.fontSize._4xl,
 | 
			
		||||
  },
 | 
			
		||||
  text_5xl: {
 | 
			
		||||
    fontSize: tokens.fontSize._5xl,
 | 
			
		||||
    lineHeight: tokens.fontSize._5xl,
 | 
			
		||||
  },
 | 
			
		||||
  leading_tight: {
 | 
			
		||||
    lineHeight: 1.15,
 | 
			
		||||
  },
 | 
			
		||||
  leading_snug: {
 | 
			
		||||
    lineHeight: 1.25,
 | 
			
		||||
  },
 | 
			
		||||
  leading_normal: {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,7 @@ import React from 'react'
 | 
			
		|||
import {Dimensions} from 'react-native'
 | 
			
		||||
import * as themes from '#/alf/themes'
 | 
			
		||||
 | 
			
		||||
export * from '#/alf/types'
 | 
			
		||||
export * as tokens from '#/alf/tokens'
 | 
			
		||||
export {atoms} from '#/alf/atoms'
 | 
			
		||||
export * from '#/alf/util/platform'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -142,6 +142,14 @@ export const gradients = {
 | 
			
		|||
    ],
 | 
			
		||||
    hover_value: '#B88BB6',
 | 
			
		||||
  },
 | 
			
		||||
  summer: {
 | 
			
		||||
    values: [
 | 
			
		||||
      [0, '#FF6A56'],
 | 
			
		||||
      [0.3, '#FF9156'],
 | 
			
		||||
      [1, '#FFDD87'],
 | 
			
		||||
    ],
 | 
			
		||||
    hover_value: '#FF9156',
 | 
			
		||||
  },
 | 
			
		||||
  nordic: {
 | 
			
		||||
    values: [
 | 
			
		||||
      [0, '#083367'],
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,5 @@
 | 
			
		|||
import {StyleProp, ViewStyle, TextStyle} from 'react-native'
 | 
			
		||||
 | 
			
		||||
type LiteralToCommon<T extends PropertyKey> = T extends number
 | 
			
		||||
  ? number
 | 
			
		||||
  : T extends string
 | 
			
		||||
| 
						 | 
				
			
			@ -14,3 +16,11 @@ export type Mutable<T> = {
 | 
			
		|||
    ? LiteralToCommon<T[K]>
 | 
			
		||||
    : Mutable<T[K]>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type TextStyleProp = {
 | 
			
		||||
  style?: StyleProp<TextStyle>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type ViewStyleProp = {
 | 
			
		||||
  style?: StyleProp<ViewStyle>
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,10 +9,11 @@ import {
 | 
			
		|||
  View,
 | 
			
		||||
  TextStyle,
 | 
			
		||||
  StyleSheet,
 | 
			
		||||
  StyleProp,
 | 
			
		||||
} from 'react-native'
 | 
			
		||||
import LinearGradient from 'react-native-linear-gradient'
 | 
			
		||||
 | 
			
		||||
import {useTheme, atoms as a, tokens, web, native} from '#/alf'
 | 
			
		||||
import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
 | 
			
		||||
import {Props as SVGIconProps} from '#/components/icons/common'
 | 
			
		||||
 | 
			
		||||
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
 | 
			
		||||
| 
						 | 
				
			
			@ -27,6 +28,7 @@ export type ButtonColor =
 | 
			
		|||
  | 'gradient_nordic'
 | 
			
		||||
  | 'gradient_bonfire'
 | 
			
		||||
export type ButtonSize = 'small' | 'large'
 | 
			
		||||
export type ButtonShape = 'round' | 'square' | 'default'
 | 
			
		||||
export type VariantProps = {
 | 
			
		||||
  /**
 | 
			
		||||
   * The style variation of the button
 | 
			
		||||
| 
						 | 
				
			
			@ -40,6 +42,10 @@ export type VariantProps = {
 | 
			
		|||
   * The size of the button
 | 
			
		||||
   */
 | 
			
		||||
  size?: ButtonSize
 | 
			
		||||
  /**
 | 
			
		||||
   * The shape of the button
 | 
			
		||||
   */
 | 
			
		||||
  shape?: ButtonShape
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type ButtonProps = React.PropsWithChildren<
 | 
			
		||||
| 
						 | 
				
			
			@ -47,6 +53,7 @@ export type ButtonProps = React.PropsWithChildren<
 | 
			
		|||
    AccessibilityProps &
 | 
			
		||||
    VariantProps & {
 | 
			
		||||
      label: string
 | 
			
		||||
      style?: StyleProp<ViewStyle>
 | 
			
		||||
    }
 | 
			
		||||
>
 | 
			
		||||
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
 | 
			
		||||
| 
						 | 
				
			
			@ -74,8 +81,10 @@ export function Button({
 | 
			
		|||
  variant,
 | 
			
		||||
  color,
 | 
			
		||||
  size,
 | 
			
		||||
  shape = 'default',
 | 
			
		||||
  label,
 | 
			
		||||
  disabled = false,
 | 
			
		||||
  style,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: ButtonProps) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
| 
						 | 
				
			
			@ -175,18 +184,18 @@ export function Button({
 | 
			
		|||
        if (!disabled) {
 | 
			
		||||
          baseStyles.push({
 | 
			
		||||
            backgroundColor: light
 | 
			
		||||
              ? tokens.color.gray_100
 | 
			
		||||
              ? tokens.color.gray_50
 | 
			
		||||
              : tokens.color.gray_900,
 | 
			
		||||
          })
 | 
			
		||||
          hoverStyles.push({
 | 
			
		||||
            backgroundColor: light
 | 
			
		||||
              ? tokens.color.gray_200
 | 
			
		||||
              ? tokens.color.gray_100
 | 
			
		||||
              : tokens.color.gray_950,
 | 
			
		||||
          })
 | 
			
		||||
        } else {
 | 
			
		||||
          baseStyles.push({
 | 
			
		||||
            backgroundColor: light
 | 
			
		||||
              ? tokens.color.gray_300
 | 
			
		||||
              ? tokens.color.gray_200
 | 
			
		||||
              : tokens.color.gray_950,
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			@ -197,7 +206,7 @@ export function Button({
 | 
			
		|||
 | 
			
		||||
        if (!disabled) {
 | 
			
		||||
          baseStyles.push(a.border, {
 | 
			
		||||
            borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500,
 | 
			
		||||
            borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700,
 | 
			
		||||
          })
 | 
			
		||||
          hoverStyles.push(a.border, t.atoms.bg_contrast_50)
 | 
			
		||||
        } else {
 | 
			
		||||
| 
						 | 
				
			
			@ -262,10 +271,28 @@ export function Button({
 | 
			
		|||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (size === 'large') {
 | 
			
		||||
      baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm)
 | 
			
		||||
    } else if (size === 'small') {
 | 
			
		||||
      baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm)
 | 
			
		||||
    if (shape === 'default') {
 | 
			
		||||
      if (size === 'large') {
 | 
			
		||||
        baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
 | 
			
		||||
      } else if (size === 'small') {
 | 
			
		||||
        baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
 | 
			
		||||
      }
 | 
			
		||||
    } else if (shape === 'round' || shape === 'square') {
 | 
			
		||||
      if (size === 'large') {
 | 
			
		||||
        if (shape === 'round') {
 | 
			
		||||
          baseStyles.push({height: 54, width: 54})
 | 
			
		||||
        } else {
 | 
			
		||||
          baseStyles.push({height: 50, width: 50})
 | 
			
		||||
        }
 | 
			
		||||
      } else if (size === 'small') {
 | 
			
		||||
        baseStyles.push({height: 40, width: 40})
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (shape === 'round') {
 | 
			
		||||
        baseStyles.push(a.rounded_full)
 | 
			
		||||
      } else if (shape === 'square') {
 | 
			
		||||
        baseStyles.push(a.rounded_sm)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
| 
						 | 
				
			
			@ -278,7 +305,7 @@ export function Button({
 | 
			
		|||
        } as ViewStyle,
 | 
			
		||||
      ],
 | 
			
		||||
    }
 | 
			
		||||
  }, [t, variant, color, size, disabled])
 | 
			
		||||
  }, [t, variant, color, size, shape, disabled])
 | 
			
		||||
 | 
			
		||||
  const {gradientColors, gradientHoverColors, gradientLocations} =
 | 
			
		||||
    React.useMemo(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -334,8 +361,10 @@ export function Button({
 | 
			
		|||
        disabled: disabled || false,
 | 
			
		||||
      }}
 | 
			
		||||
      style={[
 | 
			
		||||
        flatten(style),
 | 
			
		||||
        a.flex_row,
 | 
			
		||||
        a.align_center,
 | 
			
		||||
        a.justify_center,
 | 
			
		||||
        a.overflow_hidden,
 | 
			
		||||
        a.justify_center,
 | 
			
		||||
        ...baseStyles,
 | 
			
		||||
| 
						 | 
				
			
			@ -462,17 +491,9 @@ export function useSharedButtonTextStyles() {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    if (size === 'large') {
 | 
			
		||||
      baseStyles.push(
 | 
			
		||||
        a.text_md,
 | 
			
		||||
        web({paddingBottom: 1}),
 | 
			
		||||
        native({marginTop: 2}),
 | 
			
		||||
      )
 | 
			
		||||
      baseStyles.push(a.text_md, android({paddingBottom: 1}))
 | 
			
		||||
    } else {
 | 
			
		||||
      baseStyles.push(
 | 
			
		||||
        a.text_md,
 | 
			
		||||
        web({paddingBottom: 1}),
 | 
			
		||||
        native({marginTop: 2}),
 | 
			
		||||
      )
 | 
			
		||||
      baseStyles.push(a.text_sm, android({paddingBottom: 1}))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return StyleSheet.flatten(baseStyles)
 | 
			
		||||
| 
						 | 
				
			
			@ -491,14 +512,24 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
 | 
			
		|||
 | 
			
		||||
export function ButtonIcon({
 | 
			
		||||
  icon: Comp,
 | 
			
		||||
  position,
 | 
			
		||||
}: {
 | 
			
		||||
  icon: React.ComponentType<SVGIconProps>
 | 
			
		||||
  position?: 'left' | 'right'
 | 
			
		||||
}) {
 | 
			
		||||
  const {size} = useButtonContext()
 | 
			
		||||
  const {size, disabled} = useButtonContext()
 | 
			
		||||
  const textStyles = useSharedButtonTextStyles()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View style={[a.z_20]}>
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        a.z_20,
 | 
			
		||||
        {
 | 
			
		||||
          opacity: disabled ? 0.7 : 1,
 | 
			
		||||
          marginLeft: position === 'left' ? -2 : 0,
 | 
			
		||||
          marginRight: position === 'right' ? -2 : 0,
 | 
			
		||||
        },
 | 
			
		||||
      ]}>
 | 
			
		||||
      <Comp
 | 
			
		||||
        size={size === 'large' ? 'md' : 'sm'}
 | 
			
		||||
        style={[{color: textStyles.color, pointerEvents: 'none'}]}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										10
									
								
								src/components/Divider.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {atoms as a, useTheme} from '#/alf'
 | 
			
		||||
import {ViewStyleProp} from '#/alf'
 | 
			
		||||
 | 
			
		||||
export function Divider({style}: ViewStyleProp) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
 | 
			
		||||
  return <View style={[a.w_full, a.border_t, t.atoms.border, style]} />
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,10 +1,8 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {
 | 
			
		||||
  Text,
 | 
			
		||||
  TextStyle,
 | 
			
		||||
  StyleProp,
 | 
			
		||||
  GestureResponderEvent,
 | 
			
		||||
  Linking,
 | 
			
		||||
  TouchableWithoutFeedback,
 | 
			
		||||
} from 'react-native'
 | 
			
		||||
import {
 | 
			
		||||
  useLinkProps,
 | 
			
		||||
| 
						 | 
				
			
			@ -13,9 +11,10 @@ import {
 | 
			
		|||
} from '@react-navigation/native'
 | 
			
		||||
import {sanitizeUrl} from '@braintree/sanitize-url'
 | 
			
		||||
 | 
			
		||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
 | 
			
		||||
import {isWeb} from '#/platform/detection'
 | 
			
		||||
import {useTheme, web, flatten} from '#/alf'
 | 
			
		||||
import {Button, ButtonProps, useButtonContext} from '#/components/Button'
 | 
			
		||||
import {useTheme, web, flatten, TextStyleProp} from '#/alf'
 | 
			
		||||
import {Button, ButtonProps} from '#/components/Button'
 | 
			
		||||
import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
 | 
			
		||||
import {
 | 
			
		||||
  convertBskyAppUrlIfNeeded,
 | 
			
		||||
| 
						 | 
				
			
			@ -24,43 +23,39 @@ import {
 | 
			
		|||
} from '#/lib/strings/url-helpers'
 | 
			
		||||
import {useModalControls} from '#/state/modals'
 | 
			
		||||
import {router} from '#/routes'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
 | 
			
		||||
export type LinkProps = Omit<
 | 
			
		||||
  ButtonProps,
 | 
			
		||||
  'style' | 'onPress' | 'disabled' | 'label'
 | 
			
		||||
/**
 | 
			
		||||
 * Only available within a `Link`, since that inherits from `Button`.
 | 
			
		||||
 * `InlineLink` provides no context.
 | 
			
		||||
 */
 | 
			
		||||
export {useButtonContext as useLinkContext} from '#/components/Button'
 | 
			
		||||
 | 
			
		||||
type BaseLinkProps = Pick<
 | 
			
		||||
  Parameters<typeof useLinkProps<AllNavigatorParams>>[0],
 | 
			
		||||
  'to'
 | 
			
		||||
> & {
 | 
			
		||||
  /**
 | 
			
		||||
   * `TextStyle` to apply to the anchor element itself. Does not apply to any children.
 | 
			
		||||
   */
 | 
			
		||||
  style?: StyleProp<TextStyle>
 | 
			
		||||
  /**
 | 
			
		||||
   * The React Navigation `StackAction` to perform when the link is pressed.
 | 
			
		||||
   */
 | 
			
		||||
  action?: 'push' | 'replace' | 'navigate'
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * If true, will warn the user if the link text does not match the href. Only
 | 
			
		||||
   * works for Links with children that are strings i.e. text links.
 | 
			
		||||
   * If true, will warn the user if the link text does not match the href.
 | 
			
		||||
   *
 | 
			
		||||
   * Note: atm this only works for `InlineLink`s with a string child.
 | 
			
		||||
   */
 | 
			
		||||
  warnOnMismatchingTextChild?: boolean
 | 
			
		||||
  label?: ButtonProps['label']
 | 
			
		||||
} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'>
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A interactive element that renders as a `<a>` tag on the web. On mobile it
 | 
			
		||||
 * will translate the `href` to navigator screens and params and dispatch a
 | 
			
		||||
 * navigation action.
 | 
			
		||||
 *
 | 
			
		||||
 * Intended to behave as a web anchor tag. For more complex routing, use a
 | 
			
		||||
 * `Button`.
 | 
			
		||||
 */
 | 
			
		||||
export function Link({
 | 
			
		||||
  children,
 | 
			
		||||
export function useLink({
 | 
			
		||||
  to,
 | 
			
		||||
  displayText,
 | 
			
		||||
  action = 'push',
 | 
			
		||||
  warnOnMismatchingTextChild,
 | 
			
		||||
  style,
 | 
			
		||||
  ...rest
 | 
			
		||||
}: LinkProps) {
 | 
			
		||||
}: BaseLinkProps & {
 | 
			
		||||
  displayText: string
 | 
			
		||||
}) {
 | 
			
		||||
  const navigation = useNavigation<NavigationProp>()
 | 
			
		||||
  const {href} = useLinkProps<AllNavigatorParams>({
 | 
			
		||||
    to:
 | 
			
		||||
| 
						 | 
				
			
			@ -68,14 +63,14 @@ export function Link({
 | 
			
		|||
  })
 | 
			
		||||
  const isExternal = isExternalUrl(href)
 | 
			
		||||
  const {openModal, closeModal} = useModalControls()
 | 
			
		||||
 | 
			
		||||
  const onPress = React.useCallback(
 | 
			
		||||
    (e: GestureResponderEvent) => {
 | 
			
		||||
      const stringChildren = typeof children === 'string' ? children : ''
 | 
			
		||||
      const requiresWarning = Boolean(
 | 
			
		||||
        warnOnMismatchingTextChild &&
 | 
			
		||||
          stringChildren &&
 | 
			
		||||
          displayText &&
 | 
			
		||||
          isExternal &&
 | 
			
		||||
          linkRequiresWarning(href, stringChildren),
 | 
			
		||||
          linkRequiresWarning(href, displayText),
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      if (requiresWarning) {
 | 
			
		||||
| 
						 | 
				
			
			@ -83,7 +78,7 @@ export function Link({
 | 
			
		|||
 | 
			
		||||
        openModal({
 | 
			
		||||
          name: 'link-warning',
 | 
			
		||||
          text: stringChildren,
 | 
			
		||||
          text: displayText,
 | 
			
		||||
          href: href,
 | 
			
		||||
        })
 | 
			
		||||
      } else {
 | 
			
		||||
| 
						 | 
				
			
			@ -134,12 +129,42 @@ export function Link({
 | 
			
		|||
      warnOnMismatchingTextChild,
 | 
			
		||||
      navigation,
 | 
			
		||||
      action,
 | 
			
		||||
      children,
 | 
			
		||||
      displayText,
 | 
			
		||||
      closeModal,
 | 
			
		||||
      openModal,
 | 
			
		||||
    ],
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    isExternal,
 | 
			
		||||
    href,
 | 
			
		||||
    onPress,
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
 | 
			
		||||
  Omit<ButtonProps, 'style' | 'onPress' | 'disabled' | 'label'> & {
 | 
			
		||||
    /**
 | 
			
		||||
     * Label for a11y. Defaults to the href.
 | 
			
		||||
     */
 | 
			
		||||
    label?: string
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A interactive element that renders as a `<a>` tag on the web. On mobile it
 | 
			
		||||
 * will translate the `href` to navigator screens and params and dispatch a
 | 
			
		||||
 * navigation action.
 | 
			
		||||
 *
 | 
			
		||||
 * Intended to behave as a web anchor tag. For more complex routing, use a
 | 
			
		||||
 * `Button`.
 | 
			
		||||
 */
 | 
			
		||||
export function Link({children, to, action = 'push', ...rest}: LinkProps) {
 | 
			
		||||
  const {href, isExternal, onPress} = useLink({
 | 
			
		||||
    to,
 | 
			
		||||
    displayText: typeof children === 'string' ? children : '',
 | 
			
		||||
    action,
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Button
 | 
			
		||||
      label={href}
 | 
			
		||||
| 
						 | 
				
			
			@ -158,34 +183,81 @@ export function Link({
 | 
			
		|||
          noUnderline: '1',
 | 
			
		||||
        },
 | 
			
		||||
      })}>
 | 
			
		||||
      {typeof children === 'string' ? (
 | 
			
		||||
        <LinkText style={style}>{children}</LinkText>
 | 
			
		||||
      ) : (
 | 
			
		||||
        children
 | 
			
		||||
      )}
 | 
			
		||||
      {children}
 | 
			
		||||
    </Button>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function LinkText({
 | 
			
		||||
export type InlineLinkProps = React.PropsWithChildren<
 | 
			
		||||
  BaseLinkProps &
 | 
			
		||||
    TextStyleProp & {
 | 
			
		||||
      /**
 | 
			
		||||
       * Label for a11y. Defaults to the href.
 | 
			
		||||
       */
 | 
			
		||||
      label?: string
 | 
			
		||||
    }
 | 
			
		||||
>
 | 
			
		||||
 | 
			
		||||
export function InlineLink({
 | 
			
		||||
  children,
 | 
			
		||||
  to,
 | 
			
		||||
  action = 'push',
 | 
			
		||||
  warnOnMismatchingTextChild,
 | 
			
		||||
  style,
 | 
			
		||||
}: React.PropsWithChildren<{
 | 
			
		||||
  style?: StyleProp<TextStyle>
 | 
			
		||||
}>) {
 | 
			
		||||
  ...rest
 | 
			
		||||
}: InlineLinkProps) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const {hovered} = useButtonContext()
 | 
			
		||||
  const stringChildren = typeof children === 'string'
 | 
			
		||||
  const {href, isExternal, onPress} = useLink({
 | 
			
		||||
    to,
 | 
			
		||||
    displayText: stringChildren ? children : '',
 | 
			
		||||
    action,
 | 
			
		||||
    warnOnMismatchingTextChild,
 | 
			
		||||
  })
 | 
			
		||||
  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
 | 
			
		||||
  const {
 | 
			
		||||
    state: pressed,
 | 
			
		||||
    onIn: onPressIn,
 | 
			
		||||
    onOut: onPressOut,
 | 
			
		||||
  } = useInteractionState()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Text
 | 
			
		||||
      style={[
 | 
			
		||||
        {color: t.palette.primary_500},
 | 
			
		||||
        hovered && {
 | 
			
		||||
          textDecorationLine: 'underline',
 | 
			
		||||
          textDecorationColor: t.palette.primary_500,
 | 
			
		||||
        },
 | 
			
		||||
        flatten(style),
 | 
			
		||||
      ]}>
 | 
			
		||||
      {children as string}
 | 
			
		||||
    </Text>
 | 
			
		||||
    <TouchableWithoutFeedback
 | 
			
		||||
      accessibilityRole="button"
 | 
			
		||||
      onPress={onPress}
 | 
			
		||||
      onPressIn={onPressIn}
 | 
			
		||||
      onPressOut={onPressOut}
 | 
			
		||||
      onFocus={onFocus}
 | 
			
		||||
      onBlur={onBlur}>
 | 
			
		||||
      <Text
 | 
			
		||||
        label={href}
 | 
			
		||||
        {...rest}
 | 
			
		||||
        style={[
 | 
			
		||||
          {color: t.palette.primary_500},
 | 
			
		||||
          (focused || pressed) && {
 | 
			
		||||
            outline: 0,
 | 
			
		||||
            textDecorationLine: 'underline',
 | 
			
		||||
            textDecorationColor: t.palette.primary_500,
 | 
			
		||||
          },
 | 
			
		||||
          flatten(style),
 | 
			
		||||
        ]}
 | 
			
		||||
        role="link"
 | 
			
		||||
        accessibilityRole="link"
 | 
			
		||||
        href={href}
 | 
			
		||||
        {...web({
 | 
			
		||||
          hrefAttrs: {
 | 
			
		||||
            target: isExternal ? 'blank' : undefined,
 | 
			
		||||
            rel: isExternal ? 'noopener noreferrer' : undefined,
 | 
			
		||||
          },
 | 
			
		||||
          dataSet: stringChildren
 | 
			
		||||
            ? {}
 | 
			
		||||
            : {
 | 
			
		||||
                // default to no underline, apply this ourselves
 | 
			
		||||
                noUnderline: '1',
 | 
			
		||||
              },
 | 
			
		||||
        })}>
 | 
			
		||||
        {children}
 | 
			
		||||
      </Text>
 | 
			
		||||
    </TouchableWithoutFeedback>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,45 +12,54 @@ type ComponentMap = {
 | 
			
		|||
  [id: string]: Component
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const Context = React.createContext<ContextType>({
 | 
			
		||||
  outlet: null,
 | 
			
		||||
  append: () => {},
 | 
			
		||||
  remove: () => {},
 | 
			
		||||
})
 | 
			
		||||
export function createPortalGroup() {
 | 
			
		||||
  const Context = React.createContext<ContextType>({
 | 
			
		||||
    outlet: null,
 | 
			
		||||
    append: () => {},
 | 
			
		||||
    remove: () => {},
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
export function Provider(props: React.PropsWithChildren<{}>) {
 | 
			
		||||
  const map = React.useRef<ComponentMap>({})
 | 
			
		||||
  const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
 | 
			
		||||
  function Provider(props: React.PropsWithChildren<{}>) {
 | 
			
		||||
    const map = React.useRef<ComponentMap>({})
 | 
			
		||||
    const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
 | 
			
		||||
 | 
			
		||||
  const append = React.useCallback<ContextType['append']>((id, component) => {
 | 
			
		||||
    if (map.current[id]) return
 | 
			
		||||
    map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
 | 
			
		||||
    setOutlet(<>{Object.values(map.current)}</>)
 | 
			
		||||
  }, [])
 | 
			
		||||
    const append = React.useCallback<ContextType['append']>((id, component) => {
 | 
			
		||||
      if (map.current[id]) return
 | 
			
		||||
      map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
 | 
			
		||||
      setOutlet(<>{Object.values(map.current)}</>)
 | 
			
		||||
    }, [])
 | 
			
		||||
 | 
			
		||||
  const remove = React.useCallback<ContextType['remove']>(id => {
 | 
			
		||||
    delete map.current[id]
 | 
			
		||||
    setOutlet(<>{Object.values(map.current)}</>)
 | 
			
		||||
  }, [])
 | 
			
		||||
    const remove = React.useCallback<ContextType['remove']>(id => {
 | 
			
		||||
      delete map.current[id]
 | 
			
		||||
      setOutlet(<>{Object.values(map.current)}</>)
 | 
			
		||||
    }, [])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Context.Provider value={{outlet, append, remove}}>
 | 
			
		||||
      {props.children}
 | 
			
		||||
    </Context.Provider>
 | 
			
		||||
  )
 | 
			
		||||
    return (
 | 
			
		||||
      <Context.Provider value={{outlet, append, remove}}>
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </Context.Provider>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function Outlet() {
 | 
			
		||||
    const ctx = React.useContext(Context)
 | 
			
		||||
    return ctx.outlet
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function Portal({children}: React.PropsWithChildren<{}>) {
 | 
			
		||||
    const {append, remove} = React.useContext(Context)
 | 
			
		||||
    const id = React.useId()
 | 
			
		||||
    React.useEffect(() => {
 | 
			
		||||
      append(id, children as Component)
 | 
			
		||||
      return () => remove(id)
 | 
			
		||||
    }, [id, children, append, remove])
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {Provider, Outlet, Portal}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Outlet() {
 | 
			
		||||
  const ctx = React.useContext(Context)
 | 
			
		||||
  return ctx.outlet
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Portal({children}: React.PropsWithChildren<{}>) {
 | 
			
		||||
  const {append, remove} = React.useContext(Context)
 | 
			
		||||
  const id = React.useId()
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    append(id, children as Component)
 | 
			
		||||
    return () => remove(id)
 | 
			
		||||
  }, [id, children, append, remove])
 | 
			
		||||
  return null
 | 
			
		||||
}
 | 
			
		||||
const DefaultPortal = createPortalGroup()
 | 
			
		||||
export const Provider = DefaultPortal.Provider
 | 
			
		||||
export const Outlet = DefaultPortal.Outlet
 | 
			
		||||
export const Portal = DefaultPortal.Portal
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										131
									
								
								src/components/RichText.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,131 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
 | 
			
		||||
 | 
			
		||||
import {atoms as a, TextStyleProp} from '#/alf'
 | 
			
		||||
import {InlineLink} from '#/components/Link'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {toShortUrl} from 'lib/strings/url-helpers'
 | 
			
		||||
import {getAgent} from '#/state/session'
 | 
			
		||||
 | 
			
		||||
const WORD_WRAP = {wordWrap: 1}
 | 
			
		||||
 | 
			
		||||
export function RichText({
 | 
			
		||||
  testID,
 | 
			
		||||
  value,
 | 
			
		||||
  style,
 | 
			
		||||
  numberOfLines,
 | 
			
		||||
  disableLinks,
 | 
			
		||||
  resolveFacets = false,
 | 
			
		||||
}: TextStyleProp & {
 | 
			
		||||
  value: RichTextAPI | string
 | 
			
		||||
  testID?: string
 | 
			
		||||
  numberOfLines?: number
 | 
			
		||||
  disableLinks?: boolean
 | 
			
		||||
  resolveFacets?: boolean
 | 
			
		||||
}) {
 | 
			
		||||
  const detected = React.useRef(false)
 | 
			
		||||
  const [richText, setRichText] = React.useState<RichTextAPI>(() =>
 | 
			
		||||
    value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
 | 
			
		||||
  )
 | 
			
		||||
  const styles = [a.leading_normal, style]
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    if (!resolveFacets) return
 | 
			
		||||
 | 
			
		||||
    async function detectFacets() {
 | 
			
		||||
      const rt = new RichTextAPI({text: richText.text})
 | 
			
		||||
      await rt.detectFacets(getAgent())
 | 
			
		||||
      setRichText(rt)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!detected.current) {
 | 
			
		||||
      detected.current = true
 | 
			
		||||
      detectFacets()
 | 
			
		||||
    }
 | 
			
		||||
  }, [richText, setRichText, resolveFacets])
 | 
			
		||||
 | 
			
		||||
  const {text, facets} = richText
 | 
			
		||||
 | 
			
		||||
  if (!facets?.length) {
 | 
			
		||||
    if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
 | 
			
		||||
      return (
 | 
			
		||||
        <Text
 | 
			
		||||
          testID={testID}
 | 
			
		||||
          style={[
 | 
			
		||||
            {
 | 
			
		||||
              fontSize: 26,
 | 
			
		||||
              lineHeight: 30,
 | 
			
		||||
            },
 | 
			
		||||
          ]}
 | 
			
		||||
          // @ts-ignore web only -prf
 | 
			
		||||
          dataSet={WORD_WRAP}>
 | 
			
		||||
          {text}
 | 
			
		||||
        </Text>
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
    return (
 | 
			
		||||
      <Text
 | 
			
		||||
        testID={testID}
 | 
			
		||||
        style={styles}
 | 
			
		||||
        numberOfLines={numberOfLines}
 | 
			
		||||
        // @ts-ignore web only -prf
 | 
			
		||||
        dataSet={WORD_WRAP}>
 | 
			
		||||
        {text}
 | 
			
		||||
      </Text>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const els = []
 | 
			
		||||
  let key = 0
 | 
			
		||||
  // N.B. must access segments via `richText.segments`, not via destructuring
 | 
			
		||||
  for (const segment of richText.segments()) {
 | 
			
		||||
    const link = segment.link
 | 
			
		||||
    const mention = segment.mention
 | 
			
		||||
    if (
 | 
			
		||||
      mention &&
 | 
			
		||||
      AppBskyRichtextFacet.validateMention(mention).success &&
 | 
			
		||||
      !disableLinks
 | 
			
		||||
    ) {
 | 
			
		||||
      els.push(
 | 
			
		||||
        <InlineLink
 | 
			
		||||
          key={key}
 | 
			
		||||
          to={`/profile/${mention.did}`}
 | 
			
		||||
          style={[...styles, {pointerEvents: 'auto'}]}
 | 
			
		||||
          // @ts-ignore TODO
 | 
			
		||||
          dataSet={WORD_WRAP}>
 | 
			
		||||
          {segment.text}
 | 
			
		||||
        </InlineLink>,
 | 
			
		||||
      )
 | 
			
		||||
    } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
 | 
			
		||||
      if (disableLinks) {
 | 
			
		||||
        els.push(toShortUrl(segment.text))
 | 
			
		||||
      } else {
 | 
			
		||||
        els.push(
 | 
			
		||||
          <InlineLink
 | 
			
		||||
            key={key}
 | 
			
		||||
            to={link.uri}
 | 
			
		||||
            style={[...styles, {pointerEvents: 'auto'}]}
 | 
			
		||||
            // @ts-ignore TODO
 | 
			
		||||
            dataSet={WORD_WRAP}
 | 
			
		||||
            warnOnMismatchingLabel>
 | 
			
		||||
            {toShortUrl(segment.text)}
 | 
			
		||||
          </InlineLink>,
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      els.push(segment.text)
 | 
			
		||||
    }
 | 
			
		||||
    key++
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Text
 | 
			
		||||
      testID={testID}
 | 
			
		||||
      style={styles}
 | 
			
		||||
      numberOfLines={numberOfLines}
 | 
			
		||||
      // @ts-ignore web only -prf
 | 
			
		||||
      dataSet={WORD_WRAP}>
 | 
			
		||||
      {els}
 | 
			
		||||
    </Text>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,11 +1,50 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {Text as RNText, TextProps} from 'react-native'
 | 
			
		||||
import {Text as RNText, TextStyle, TextProps} from 'react-native'
 | 
			
		||||
 | 
			
		||||
import {useTheme, atoms, web, flatten} from '#/alf'
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Util to calculate lineHeight from a text size atom and a leading atom
 | 
			
		||||
 *
 | 
			
		||||
 * Example:
 | 
			
		||||
 *   `leading(atoms.text_md, atoms.leading_normal)` // => 24
 | 
			
		||||
 */
 | 
			
		||||
export function leading<
 | 
			
		||||
  Size extends {fontSize?: number},
 | 
			
		||||
  Leading extends {lineHeight?: number},
 | 
			
		||||
>(textSize: Size, leading: Leading) {
 | 
			
		||||
  const size = textSize?.fontSize || atoms.text_md.fontSize
 | 
			
		||||
  const lineHeight = leading?.lineHeight || atoms.leading_normal.lineHeight
 | 
			
		||||
  return size * lineHeight
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Ensures that `lineHeight` defaults to a relative value of `1`, or applies
 | 
			
		||||
 * other relative leading atoms.
 | 
			
		||||
 *
 | 
			
		||||
 * If the `lineHeight` value is > 2, we assume it's an absolute value and
 | 
			
		||||
 * returns it as-is.
 | 
			
		||||
 */
 | 
			
		||||
function normalizeTextStyles(styles: TextStyle[]) {
 | 
			
		||||
  const s = flatten(styles)
 | 
			
		||||
  // should always be defined on these components
 | 
			
		||||
  const fontSize = s.fontSize || atoms.text_md.fontSize
 | 
			
		||||
 | 
			
		||||
  if (s?.lineHeight) {
 | 
			
		||||
    if (s.lineHeight <= 2) {
 | 
			
		||||
      s.lineHeight = fontSize * s.lineHeight
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    s.lineHeight = fontSize
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return s
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Text({style, ...rest}: TextProps) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} />
 | 
			
		||||
  const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
 | 
			
		||||
  return <RNText style={s} {...rest} />
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function H1({style, ...rest}: TextProps) {
 | 
			
		||||
| 
						 | 
				
			
			@ -19,7 +58,12 @@ export function H1({style, ...rest}: TextProps) {
 | 
			
		|||
    <RNText
 | 
			
		||||
      {...attr}
 | 
			
		||||
      {...rest}
 | 
			
		||||
      style={[atoms.text_5xl, atoms.font_bold, t.atoms.text, flatten(style)]}
 | 
			
		||||
      style={normalizeTextStyles([
 | 
			
		||||
        atoms.text_5xl,
 | 
			
		||||
        atoms.font_bold,
 | 
			
		||||
        t.atoms.text,
 | 
			
		||||
        flatten(style),
 | 
			
		||||
      ])}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -35,7 +79,12 @@ export function H2({style, ...rest}: TextProps) {
 | 
			
		|||
    <RNText
 | 
			
		||||
      {...attr}
 | 
			
		||||
      {...rest}
 | 
			
		||||
      style={[atoms.text_4xl, atoms.font_bold, t.atoms.text, flatten(style)]}
 | 
			
		||||
      style={normalizeTextStyles([
 | 
			
		||||
        atoms.text_4xl,
 | 
			
		||||
        atoms.font_bold,
 | 
			
		||||
        t.atoms.text,
 | 
			
		||||
        flatten(style),
 | 
			
		||||
      ])}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -51,7 +100,12 @@ export function H3({style, ...rest}: TextProps) {
 | 
			
		|||
    <RNText
 | 
			
		||||
      {...attr}
 | 
			
		||||
      {...rest}
 | 
			
		||||
      style={[atoms.text_3xl, atoms.font_bold, t.atoms.text, flatten(style)]}
 | 
			
		||||
      style={normalizeTextStyles([
 | 
			
		||||
        atoms.text_3xl,
 | 
			
		||||
        atoms.font_bold,
 | 
			
		||||
        t.atoms.text,
 | 
			
		||||
        flatten(style),
 | 
			
		||||
      ])}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -67,7 +121,12 @@ export function H4({style, ...rest}: TextProps) {
 | 
			
		|||
    <RNText
 | 
			
		||||
      {...attr}
 | 
			
		||||
      {...rest}
 | 
			
		||||
      style={[atoms.text_2xl, atoms.font_bold, t.atoms.text, flatten(style)]}
 | 
			
		||||
      style={normalizeTextStyles([
 | 
			
		||||
        atoms.text_2xl,
 | 
			
		||||
        atoms.font_bold,
 | 
			
		||||
        t.atoms.text,
 | 
			
		||||
        flatten(style),
 | 
			
		||||
      ])}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -83,7 +142,12 @@ export function H5({style, ...rest}: TextProps) {
 | 
			
		|||
    <RNText
 | 
			
		||||
      {...attr}
 | 
			
		||||
      {...rest}
 | 
			
		||||
      style={[atoms.text_xl, atoms.font_bold, t.atoms.text, flatten(style)]}
 | 
			
		||||
      style={normalizeTextStyles([
 | 
			
		||||
        atoms.text_xl,
 | 
			
		||||
        atoms.font_bold,
 | 
			
		||||
        t.atoms.text,
 | 
			
		||||
        flatten(style),
 | 
			
		||||
      ])}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -99,7 +163,12 @@ export function H6({style, ...rest}: TextProps) {
 | 
			
		|||
    <RNText
 | 
			
		||||
      {...attr}
 | 
			
		||||
      {...rest}
 | 
			
		||||
      style={[atoms.text_lg, atoms.font_bold, t.atoms.text, flatten(style)]}
 | 
			
		||||
      style={normalizeTextStyles([
 | 
			
		||||
        atoms.text_lg,
 | 
			
		||||
        atoms.font_bold,
 | 
			
		||||
        t.atoms.text,
 | 
			
		||||
        flatten(style),
 | 
			
		||||
      ])}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -110,15 +179,16 @@ export function P({style, ...rest}: TextProps) {
 | 
			
		|||
    web({
 | 
			
		||||
      role: 'paragraph',
 | 
			
		||||
    }) || {}
 | 
			
		||||
  const _style = flatten(style)
 | 
			
		||||
  const lineHeight =
 | 
			
		||||
    (_style?.lineHeight || atoms.text_md.lineHeight) *
 | 
			
		||||
    atoms.leading_normal.lineHeight
 | 
			
		||||
  return (
 | 
			
		||||
    <RNText
 | 
			
		||||
      {...attr}
 | 
			
		||||
      {...rest}
 | 
			
		||||
      style={[atoms.text_md, t.atoms.text, _style, {lineHeight}]}
 | 
			
		||||
      style={normalizeTextStyles([
 | 
			
		||||
        atoms.text_md,
 | 
			
		||||
        atoms.leading_normal,
 | 
			
		||||
        t.atoms.text,
 | 
			
		||||
        flatten(style),
 | 
			
		||||
      ])}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -208,7 +208,7 @@ export function createInput(Component: typeof TextInput) {
 | 
			
		|||
              paddingBottom: 2,
 | 
			
		||||
            }),
 | 
			
		||||
            {
 | 
			
		||||
              lineHeight: a.text_md.lineHeight * 1.1875,
 | 
			
		||||
              lineHeight: a.text_md.fontSize * 1.1875,
 | 
			
		||||
              textAlignVertical: rest.multiline ? 'top' : undefined,
 | 
			
		||||
              minHeight: rest.multiline ? 60 : undefined,
 | 
			
		||||
            },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ import React from 'react'
 | 
			
		|||
import {Pressable, View, ViewStyle} from 'react-native'
 | 
			
		||||
 | 
			
		||||
import {HITSLOP_10} from 'lib/constants'
 | 
			
		||||
import {useTheme, atoms as a, web, native} from '#/alf'
 | 
			
		||||
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -49,7 +49,7 @@ export type GroupProps = React.PropsWithChildren<{
 | 
			
		|||
  label: string
 | 
			
		||||
}>
 | 
			
		||||
 | 
			
		||||
export type ItemProps = {
 | 
			
		||||
export type ItemProps = ViewStyleProp & {
 | 
			
		||||
  type?: 'radio' | 'checkbox'
 | 
			
		||||
  name: string
 | 
			
		||||
  label: string
 | 
			
		||||
| 
						 | 
				
			
			@ -57,7 +57,6 @@ export type ItemProps = {
 | 
			
		|||
  disabled?: boolean
 | 
			
		||||
  onChange?: (selected: boolean) => void
 | 
			
		||||
  isInvalid?: boolean
 | 
			
		||||
  style?: (state: ItemState) => ViewStyle
 | 
			
		||||
  children: ((props: ItemState) => React.ReactNode) | React.ReactNode
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -125,6 +124,7 @@ export function Group({
 | 
			
		|||
  return (
 | 
			
		||||
    <GroupContext.Provider value={context}>
 | 
			
		||||
      <View
 | 
			
		||||
        style={[a.w_full]}
 | 
			
		||||
        role={groupRole}
 | 
			
		||||
        {...(groupRole === 'radiogroup'
 | 
			
		||||
          ? {
 | 
			
		||||
| 
						 | 
				
			
			@ -224,7 +224,7 @@ export function Item({
 | 
			
		|||
          a.align_center,
 | 
			
		||||
          a.gap_sm,
 | 
			
		||||
          focused ? web({outline: 'none'}) : {},
 | 
			
		||||
          style?.(state),
 | 
			
		||||
          flatten(style),
 | 
			
		||||
        ]}>
 | 
			
		||||
        {typeof children === 'function' ? children(state) : children}
 | 
			
		||||
      </Pressable>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -20,6 +20,7 @@ export function Group({children, multiple, ...props}: GroupProps) {
 | 
			
		|||
    <Toggle.Group type={multiple ? 'checkbox' : 'radio'} {...props}>
 | 
			
		||||
      <View
 | 
			
		||||
        style={[
 | 
			
		||||
          a.w_full,
 | 
			
		||||
          a.flex_row,
 | 
			
		||||
          a.border,
 | 
			
		||||
          a.rounded_sm,
 | 
			
		||||
| 
						 | 
				
			
			@ -34,7 +35,7 @@ export function Group({children, multiple, ...props}: GroupProps) {
 | 
			
		|||
 | 
			
		||||
export function Button({children, ...props}: ItemProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <Toggle.Item {...props}>
 | 
			
		||||
    <Toggle.Item {...props} style={[a.flex_grow]}>
 | 
			
		||||
      <ButtonInner>{children}</ButtonInner>
 | 
			
		||||
    </Toggle.Item>
 | 
			
		||||
  )
 | 
			
		||||
| 
						 | 
				
			
			@ -95,11 +96,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
 | 
			
		|||
          borderLeftWidth: 1,
 | 
			
		||||
          marginLeft: -1,
 | 
			
		||||
        },
 | 
			
		||||
        a.px_lg,
 | 
			
		||||
        a.flex_grow,
 | 
			
		||||
        a.py_md,
 | 
			
		||||
        native({
 | 
			
		||||
          paddingTop: 14,
 | 
			
		||||
          paddingBottom: 10,
 | 
			
		||||
        }),
 | 
			
		||||
        a.px_sm,
 | 
			
		||||
        t.atoms.bg,
 | 
			
		||||
        t.atoms.border,
 | 
			
		||||
        baseStyles,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										6
									
								
								src/components/icons/ArrowRotateCounterClockwise.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded =
 | 
			
		||||
  createSinglePathSVG({
 | 
			
		||||
    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',
 | 
			
		||||
  })
 | 
			
		||||
							
								
								
									
										5
									
								
								src/components/icons/At.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const At_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/Check.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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',
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										9
									
								
								src/components/icons/Chevron.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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',
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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',
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										5
									
								
								src/components/icons/CircleInfo.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const CircleInfo_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/Emoji.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/EyeSlash.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const EyeSlash_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/FilterTimeline.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const FilterTimeline_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/Growth.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const Growth_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/Hashtag.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const Hashtag_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/ListMagnifyingGlass.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const ListMagnifyingGlass_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/ListSparkle.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const ListSparkle_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/News2.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const News2_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/Plus.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({
 | 
			
		||||
  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
									
								
								src/components/icons/Trending2.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import {createSinglePathSVG} from './TEMPLATE'
 | 
			
		||||
 | 
			
		||||
export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({
 | 
			
		||||
  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',
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			@ -131,6 +131,38 @@ interface TrackPropertiesMap {
 | 
			
		|||
  'Onboarding:Reset': {}
 | 
			
		||||
  'Onboarding:SuggestedFollowFollowed': {}
 | 
			
		||||
  'Onboarding:CustomFeedAdded': {}
 | 
			
		||||
  // Onboarding v2
 | 
			
		||||
  'OnboardingV2:Begin': {}
 | 
			
		||||
  'OnboardingV2:StepInterests:Start': {}
 | 
			
		||||
  'OnboardingV2:StepInterests:End': {
 | 
			
		||||
    selectedInterests: string[]
 | 
			
		||||
    selectedInterestsLength: number
 | 
			
		||||
  }
 | 
			
		||||
  'OnboardingV2:StepInterests:Error': {}
 | 
			
		||||
  'OnboardingV2:StepSuggestedAccounts:Start': {}
 | 
			
		||||
  'OnboardingV2:StepSuggestedAccounts:End': {
 | 
			
		||||
    selectedAccountsLength: number
 | 
			
		||||
  }
 | 
			
		||||
  'OnboardingV2:StepFollowingFeed:Start': {}
 | 
			
		||||
  'OnboardingV2:StepFollowingFeed:End': {}
 | 
			
		||||
  'OnboardingV2:StepAlgoFeeds:Start': {}
 | 
			
		||||
  'OnboardingV2:StepAlgoFeeds:End': {
 | 
			
		||||
    selectedPrimaryFeeds: string[]
 | 
			
		||||
    selectedPrimaryFeedsLength: number
 | 
			
		||||
    selectedSecondaryFeeds: string[]
 | 
			
		||||
    selectedSecondaryFeedsLength: number
 | 
			
		||||
  }
 | 
			
		||||
  'OnboardingV2:StepTopicalFeeds:Start': {}
 | 
			
		||||
  'OnboardingV2:StepTopicalFeeds:End': {
 | 
			
		||||
    selectedFeeds: string[]
 | 
			
		||||
    selectedFeedsLength: number
 | 
			
		||||
  }
 | 
			
		||||
  'OnboardingV2:StepModeration:Start': {}
 | 
			
		||||
  'OnboardingV2:StepModeration:End': {}
 | 
			
		||||
  'OnboardingV2:StepFinished:Start': {}
 | 
			
		||||
  'OnboardingV2:StepFinished:End': {}
 | 
			
		||||
  'OnboardingV2:Complete': {}
 | 
			
		||||
  'OnboardingV2:Skip': {}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface ScreenPropertiesMap {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,2 +1,3 @@
 | 
			
		|||
export const LOGIN_INCLUDE_DEV_SERVERS = true
 | 
			
		||||
export const PWI_ENABLED = true
 | 
			
		||||
export const NEW_ONBOARDING_ENABLED = false
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										51
									
								
								src/screens/Onboarding/IconCircle.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,51 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  useTheme,
 | 
			
		||||
  atoms as a,
 | 
			
		||||
  ViewStyleProp,
 | 
			
		||||
  TextStyleProp,
 | 
			
		||||
  flatten,
 | 
			
		||||
} from '#/alf'
 | 
			
		||||
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
 | 
			
		||||
import {Props} from '#/components/icons/common'
 | 
			
		||||
 | 
			
		||||
export function IconCircle({
 | 
			
		||||
  icon: Icon,
 | 
			
		||||
  size = 'xl',
 | 
			
		||||
  style,
 | 
			
		||||
  iconStyle,
 | 
			
		||||
}: ViewStyleProp & {
 | 
			
		||||
  icon: typeof Growth
 | 
			
		||||
  size?: Props['size']
 | 
			
		||||
  iconStyle?: TextStyleProp['style']
 | 
			
		||||
}) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        a.justify_center,
 | 
			
		||||
        a.align_center,
 | 
			
		||||
        a.rounded_full,
 | 
			
		||||
        {
 | 
			
		||||
          width: 64,
 | 
			
		||||
          height: 64,
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
            t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
 | 
			
		||||
        },
 | 
			
		||||
        flatten(style),
 | 
			
		||||
      ]}>
 | 
			
		||||
      <Icon
 | 
			
		||||
        size={size}
 | 
			
		||||
        style={[
 | 
			
		||||
          {
 | 
			
		||||
            color: t.palette.primary_500,
 | 
			
		||||
          },
 | 
			
		||||
          flatten(iconStyle),
 | 
			
		||||
        ]}
 | 
			
		||||
      />
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										231
									
								
								src/screens/Onboarding/Layout.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,231 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {IS_DEV} from '#/env'
 | 
			
		||||
import {isWeb} from '#/platform/detection'
 | 
			
		||||
import {useOnboardingDispatch} from '#/state/shell'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  useTheme,
 | 
			
		||||
  atoms as a,
 | 
			
		||||
  useBreakpoints,
 | 
			
		||||
  web,
 | 
			
		||||
  native,
 | 
			
		||||
  flatten,
 | 
			
		||||
  TextStyleProp,
 | 
			
		||||
} from '#/alf'
 | 
			
		||||
import {H2, P, leading} from '#/components/Typography'
 | 
			
		||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
 | 
			
		||||
import {Button, ButtonIcon} from '#/components/Button'
 | 
			
		||||
import {ScrollView} from '#/view/com/util/Views'
 | 
			
		||||
import {createPortalGroup} from '#/components/Portal'
 | 
			
		||||
 | 
			
		||||
import {Context} from '#/screens/Onboarding/state'
 | 
			
		||||
 | 
			
		||||
const COL_WIDTH = 500
 | 
			
		||||
 | 
			
		||||
export const OnboardingControls = createPortalGroup()
 | 
			
		||||
 | 
			
		||||
export function Layout({children}: React.PropsWithChildren<{}>) {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const insets = useSafeAreaInsets()
 | 
			
		||||
  const {gtMobile} = useBreakpoints()
 | 
			
		||||
  const onboardDispatch = useOnboardingDispatch()
 | 
			
		||||
  const {state, dispatch} = React.useContext(Context)
 | 
			
		||||
  const scrollview = React.useRef<ScrollView>(null)
 | 
			
		||||
  const prevActiveStep = React.useRef<string>(state.activeStep)
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    if (state.activeStep !== prevActiveStep.current) {
 | 
			
		||||
      prevActiveStep.current = state.activeStep
 | 
			
		||||
      scrollview.current?.scrollTo({y: 0, animated: false})
 | 
			
		||||
    }
 | 
			
		||||
  }, [state])
 | 
			
		||||
 | 
			
		||||
  const paddingTop = gtMobile ? a.py_5xl : a.py_lg
 | 
			
		||||
  const dialogLabel = _(msg`Set up your account`)
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      aria-modal
 | 
			
		||||
      role="dialog"
 | 
			
		||||
      aria-role="dialog"
 | 
			
		||||
      aria-label={dialogLabel}
 | 
			
		||||
      accessibilityLabel={dialogLabel}
 | 
			
		||||
      accessibilityHint={_(
 | 
			
		||||
        msg`The following steps will help customize your Bluesky experience.`,
 | 
			
		||||
      )}
 | 
			
		||||
      style={[
 | 
			
		||||
        // @ts-ignore web only -prf
 | 
			
		||||
        isWeb ? a.fixed : a.absolute,
 | 
			
		||||
        a.inset_0,
 | 
			
		||||
        a.flex_1,
 | 
			
		||||
        t.atoms.bg,
 | 
			
		||||
      ]}>
 | 
			
		||||
      {IS_DEV && (
 | 
			
		||||
        <View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}>
 | 
			
		||||
          <Button
 | 
			
		||||
            variant="ghost"
 | 
			
		||||
            color="negative"
 | 
			
		||||
            size="small"
 | 
			
		||||
            onPress={() => onboardDispatch({type: 'skip'})}
 | 
			
		||||
            // DEV ONLY
 | 
			
		||||
            label="Clear onboarding state">
 | 
			
		||||
            Clear
 | 
			
		||||
          </Button>
 | 
			
		||||
        </View>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {!gtMobile && state.hasPrev && (
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            web(a.fixed),
 | 
			
		||||
            native(a.absolute),
 | 
			
		||||
            a.flex_row,
 | 
			
		||||
            a.w_full,
 | 
			
		||||
            a.justify_center,
 | 
			
		||||
            a.z_20,
 | 
			
		||||
            a.px_xl,
 | 
			
		||||
            {
 | 
			
		||||
              top: paddingTop.paddingTop + insets.top - 1,
 | 
			
		||||
            },
 | 
			
		||||
          ]}>
 | 
			
		||||
          <View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}>
 | 
			
		||||
            <Button
 | 
			
		||||
              key={state.activeStep} // remove focus state on nav
 | 
			
		||||
              variant="ghost"
 | 
			
		||||
              color="secondary"
 | 
			
		||||
              size="small"
 | 
			
		||||
              shape="round"
 | 
			
		||||
              label={_(msg`Go back to previous step`)}
 | 
			
		||||
              style={[a.absolute]}
 | 
			
		||||
              onPress={() => dispatch({type: 'prev'})}>
 | 
			
		||||
              <ButtonIcon icon={ChevronLeft} />
 | 
			
		||||
            </Button>
 | 
			
		||||
          </View>
 | 
			
		||||
        </View>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <ScrollView
 | 
			
		||||
        ref={scrollview}
 | 
			
		||||
        style={[a.h_full, a.w_full, {paddingTop: insets.top}]}
 | 
			
		||||
        contentContainerStyle={{borderWidth: 0}}
 | 
			
		||||
        // @ts-ignore web only --prf
 | 
			
		||||
        dataSet={{'stable-gutters': 1}}>
 | 
			
		||||
        <View
 | 
			
		||||
          style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}>
 | 
			
		||||
          <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
 | 
			
		||||
            <View style={[a.w_full, a.align_center, paddingTop]}>
 | 
			
		||||
              <View
 | 
			
		||||
                style={[
 | 
			
		||||
                  a.flex_row,
 | 
			
		||||
                  a.gap_sm,
 | 
			
		||||
                  a.w_full,
 | 
			
		||||
                  {paddingTop: 17, maxWidth: '60%'},
 | 
			
		||||
                ]}>
 | 
			
		||||
                {Array(state.totalSteps)
 | 
			
		||||
                  .fill(0)
 | 
			
		||||
                  .map((_, i) => (
 | 
			
		||||
                    <View
 | 
			
		||||
                      key={i}
 | 
			
		||||
                      style={[
 | 
			
		||||
                        a.flex_1,
 | 
			
		||||
                        a.pt_xs,
 | 
			
		||||
                        a.rounded_full,
 | 
			
		||||
                        t.atoms.bg_contrast_50,
 | 
			
		||||
                        {
 | 
			
		||||
                          backgroundColor:
 | 
			
		||||
                            i + 1 <= state.activeStepIndex
 | 
			
		||||
                              ? t.palette.primary_500
 | 
			
		||||
                              : t.palette.contrast_100,
 | 
			
		||||
                        },
 | 
			
		||||
                      ]}
 | 
			
		||||
                    />
 | 
			
		||||
                  ))}
 | 
			
		||||
              </View>
 | 
			
		||||
            </View>
 | 
			
		||||
 | 
			
		||||
            <View
 | 
			
		||||
              style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}>
 | 
			
		||||
              {children}
 | 
			
		||||
            </View>
 | 
			
		||||
 | 
			
		||||
            <View style={{height: 200}} />
 | 
			
		||||
          </View>
 | 
			
		||||
        </View>
 | 
			
		||||
      </ScrollView>
 | 
			
		||||
 | 
			
		||||
      <View
 | 
			
		||||
        style={[
 | 
			
		||||
          // @ts-ignore web only -prf
 | 
			
		||||
          isWeb ? a.fixed : a.absolute,
 | 
			
		||||
          {bottom: 0, left: 0, right: 0},
 | 
			
		||||
          t.atoms.bg,
 | 
			
		||||
          t.atoms.border,
 | 
			
		||||
          a.border_t,
 | 
			
		||||
          a.align_center,
 | 
			
		||||
          gtMobile ? a.px_5xl : a.px_xl,
 | 
			
		||||
          isWeb
 | 
			
		||||
            ? a.py_2xl
 | 
			
		||||
            : {
 | 
			
		||||
                paddingTop: a.pt_lg.paddingTop,
 | 
			
		||||
                paddingBottom: insets.bottom,
 | 
			
		||||
              },
 | 
			
		||||
        ]}>
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            a.w_full,
 | 
			
		||||
            {maxWidth: COL_WIDTH},
 | 
			
		||||
            gtMobile && [a.flex_row, a.justify_between],
 | 
			
		||||
          ]}>
 | 
			
		||||
          {gtMobile &&
 | 
			
		||||
            (state.hasPrev ? (
 | 
			
		||||
              <Button
 | 
			
		||||
                key={state.activeStep} // remove focus state on nav
 | 
			
		||||
                variant="solid"
 | 
			
		||||
                color="secondary"
 | 
			
		||||
                size="large"
 | 
			
		||||
                shape="round"
 | 
			
		||||
                label={_(msg`Go back to previous step`)}
 | 
			
		||||
                onPress={() => dispatch({type: 'prev'})}>
 | 
			
		||||
                <ButtonIcon icon={ChevronLeft} />
 | 
			
		||||
              </Button>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <View style={{height: 54}} />
 | 
			
		||||
            ))}
 | 
			
		||||
          <OnboardingControls.Outlet />
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Title({
 | 
			
		||||
  children,
 | 
			
		||||
  style,
 | 
			
		||||
}: React.PropsWithChildren<TextStyleProp>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <H2
 | 
			
		||||
      style={[
 | 
			
		||||
        a.pb_sm,
 | 
			
		||||
        {
 | 
			
		||||
          lineHeight: leading(a.text_4xl, a.leading_tight),
 | 
			
		||||
        },
 | 
			
		||||
        flatten(style),
 | 
			
		||||
      ]}>
 | 
			
		||||
      {children}
 | 
			
		||||
    </H2>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Description({
 | 
			
		||||
  children,
 | 
			
		||||
  style,
 | 
			
		||||
}: React.PropsWithChildren<TextStyleProp>) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  return <P style={[t.atoms.text_contrast_700, flatten(style)]}>{children}</P>
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										378
									
								
								src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,378 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import LinearGradient from 'react-native-linear-gradient'
 | 
			
		||||
import {Image} from 'expo-image'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {useTheme, atoms as a} from '#/alf'
 | 
			
		||||
import * as Toggle from '#/components/forms/Toggle'
 | 
			
		||||
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
 | 
			
		||||
import {Text, H3} from '#/components/Typography'
 | 
			
		||||
import {RichText} from '#/components/RichText'
 | 
			
		||||
 | 
			
		||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 | 
			
		||||
import {FeedConfig} from '#/screens/Onboarding/StepAlgoFeeds'
 | 
			
		||||
 | 
			
		||||
function PrimaryFeedCardInner({
 | 
			
		||||
  feed,
 | 
			
		||||
  config,
 | 
			
		||||
}: {
 | 
			
		||||
  feed: FeedSourceInfo
 | 
			
		||||
  config: FeedConfig
 | 
			
		||||
}) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const ctx = Toggle.useItemContext()
 | 
			
		||||
 | 
			
		||||
  const styles = React.useMemo(
 | 
			
		||||
    () => ({
 | 
			
		||||
      active: [t.atoms.bg_contrast_25],
 | 
			
		||||
      selected: [
 | 
			
		||||
        a.shadow_md,
 | 
			
		||||
        {
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
            t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
      selectedHover: [
 | 
			
		||||
        {
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
            t.name === 'light' ? t.palette.primary_25 : t.palette.primary_975,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
      textSelected: [{color: t.palette.white}],
 | 
			
		||||
      checkboxSelected: [
 | 
			
		||||
        {
 | 
			
		||||
          borderColor: t.palette.white,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    }),
 | 
			
		||||
    [t],
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        a.relative,
 | 
			
		||||
        a.w_full,
 | 
			
		||||
        a.p_lg,
 | 
			
		||||
        a.rounded_md,
 | 
			
		||||
        a.overflow_hidden,
 | 
			
		||||
        t.atoms.bg_contrast_50,
 | 
			
		||||
        (ctx.hovered || ctx.focused || ctx.pressed) && styles.active,
 | 
			
		||||
        ctx.selected && styles.selected,
 | 
			
		||||
        ctx.selected &&
 | 
			
		||||
          (ctx.hovered || ctx.focused || ctx.pressed) &&
 | 
			
		||||
          styles.selectedHover,
 | 
			
		||||
      ]}>
 | 
			
		||||
      {ctx.selected && config.gradient && (
 | 
			
		||||
        <LinearGradient
 | 
			
		||||
          colors={config.gradient.values.map(v => v[1])}
 | 
			
		||||
          locations={config.gradient.values.map(v => v[0])}
 | 
			
		||||
          start={{x: 0, y: 0}}
 | 
			
		||||
          end={{x: 1, y: 1}}
 | 
			
		||||
          style={[a.absolute, a.inset_0]}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            {
 | 
			
		||||
              width: 64,
 | 
			
		||||
              height: 64,
 | 
			
		||||
            },
 | 
			
		||||
            a.rounded_sm,
 | 
			
		||||
            a.overflow_hidden,
 | 
			
		||||
            t.atoms.bg,
 | 
			
		||||
          ]}>
 | 
			
		||||
          <Image
 | 
			
		||||
            source={{uri: feed.avatar}}
 | 
			
		||||
            style={[a.w_full, a.h_full]}
 | 
			
		||||
            accessibilityIgnoresInvertColors
 | 
			
		||||
          />
 | 
			
		||||
        </View>
 | 
			
		||||
 | 
			
		||||
        <View style={[a.pt_xs, a.flex_grow]}>
 | 
			
		||||
          <H3
 | 
			
		||||
            style={[
 | 
			
		||||
              a.text_lg,
 | 
			
		||||
              a.font_bold,
 | 
			
		||||
              ctx.selected && styles.textSelected,
 | 
			
		||||
            ]}>
 | 
			
		||||
            {feed.displayName}
 | 
			
		||||
          </H3>
 | 
			
		||||
 | 
			
		||||
          <Text
 | 
			
		||||
            style={[
 | 
			
		||||
              {opacity: 0.6},
 | 
			
		||||
              a.text_md,
 | 
			
		||||
              a.py_xs,
 | 
			
		||||
              ctx.selected && styles.textSelected,
 | 
			
		||||
            ]}>
 | 
			
		||||
            by @{feed.creatorHandle}
 | 
			
		||||
          </Text>
 | 
			
		||||
        </View>
 | 
			
		||||
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            {
 | 
			
		||||
              width: 28,
 | 
			
		||||
              height: 28,
 | 
			
		||||
            },
 | 
			
		||||
            a.justify_center,
 | 
			
		||||
            a.align_center,
 | 
			
		||||
            a.rounded_sm,
 | 
			
		||||
            ctx.selected ? [a.border, styles.checkboxSelected] : t.atoms.bg,
 | 
			
		||||
          ]}>
 | 
			
		||||
          {ctx.selected && <Check size="sm" fill={t.palette.white} />}
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <View
 | 
			
		||||
        style={[
 | 
			
		||||
          {
 | 
			
		||||
            opacity: ctx.selected ? 0.3 : 1,
 | 
			
		||||
            borderTopWidth: 1,
 | 
			
		||||
          },
 | 
			
		||||
          a.mt_md,
 | 
			
		||||
          a.w_full,
 | 
			
		||||
          t.name === 'light' ? t.atoms.border : t.atoms.border_contrast,
 | 
			
		||||
          ctx.selected && {
 | 
			
		||||
            borderTopColor: t.palette.white,
 | 
			
		||||
          },
 | 
			
		||||
        ]}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <View style={[a.pt_md]}>
 | 
			
		||||
        <RichText
 | 
			
		||||
          value={feed.description}
 | 
			
		||||
          style={[
 | 
			
		||||
            a.text_md,
 | 
			
		||||
            ctx.selected &&
 | 
			
		||||
              (t.name === 'light'
 | 
			
		||||
                ? t.atoms.text_inverted
 | 
			
		||||
                : {color: t.palette.white}),
 | 
			
		||||
          ]}
 | 
			
		||||
          disableLinks
 | 
			
		||||
        />
 | 
			
		||||
      </View>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function PrimaryFeedCard({config}: {config: FeedConfig}) {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const {data: feed} = useFeedSourceInfoQuery({uri: config.uri})
 | 
			
		||||
 | 
			
		||||
  return !feed ? (
 | 
			
		||||
    <FeedCardPlaceholder primary />
 | 
			
		||||
  ) : (
 | 
			
		||||
    <Toggle.Item
 | 
			
		||||
      name={feed.uri}
 | 
			
		||||
      label={_(msg`Subscribe to the ${feed.displayName} feed`)}>
 | 
			
		||||
      <PrimaryFeedCardInner config={config} feed={feed} />
 | 
			
		||||
    </Toggle.Item>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function FeedCardInner({feed}: {feed: FeedSourceInfo; config: FeedConfig}) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const ctx = Toggle.useItemContext()
 | 
			
		||||
 | 
			
		||||
  const styles = React.useMemo(
 | 
			
		||||
    () => ({
 | 
			
		||||
      active: [t.atoms.bg_contrast_25],
 | 
			
		||||
      selected: [
 | 
			
		||||
        {
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
            t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
      selectedHover: [
 | 
			
		||||
        {
 | 
			
		||||
          backgroundColor:
 | 
			
		||||
            t.name === 'light' ? t.palette.primary_25 : t.palette.primary_975,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
      textSelected: [],
 | 
			
		||||
      checkboxSelected: [
 | 
			
		||||
        {
 | 
			
		||||
          backgroundColor: t.palette.primary_500,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    }),
 | 
			
		||||
    [t],
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        a.relative,
 | 
			
		||||
        a.w_full,
 | 
			
		||||
        a.p_md,
 | 
			
		||||
        a.rounded_md,
 | 
			
		||||
        a.overflow_hidden,
 | 
			
		||||
        t.atoms.bg_contrast_50,
 | 
			
		||||
        (ctx.hovered || ctx.focused || ctx.pressed) && styles.active,
 | 
			
		||||
        ctx.selected && styles.selected,
 | 
			
		||||
        ctx.selected &&
 | 
			
		||||
          (ctx.hovered || ctx.focused || ctx.pressed) &&
 | 
			
		||||
          styles.selectedHover,
 | 
			
		||||
      ]}>
 | 
			
		||||
      <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            {
 | 
			
		||||
              width: 44,
 | 
			
		||||
              height: 44,
 | 
			
		||||
            },
 | 
			
		||||
            a.rounded_sm,
 | 
			
		||||
            a.overflow_hidden,
 | 
			
		||||
            t.atoms.bg,
 | 
			
		||||
          ]}>
 | 
			
		||||
          <Image
 | 
			
		||||
            source={{uri: feed.avatar}}
 | 
			
		||||
            style={[a.w_full, a.h_full]}
 | 
			
		||||
            accessibilityIgnoresInvertColors
 | 
			
		||||
          />
 | 
			
		||||
        </View>
 | 
			
		||||
 | 
			
		||||
        <View style={[a.pt_2xs, a.flex_grow]}>
 | 
			
		||||
          <H3
 | 
			
		||||
            style={[
 | 
			
		||||
              a.text_md,
 | 
			
		||||
              a.font_bold,
 | 
			
		||||
              ctx.selected && styles.textSelected,
 | 
			
		||||
            ]}>
 | 
			
		||||
            {feed.displayName}
 | 
			
		||||
          </H3>
 | 
			
		||||
          <Text
 | 
			
		||||
            style={[
 | 
			
		||||
              {opacity: 0.8},
 | 
			
		||||
              a.pt_xs,
 | 
			
		||||
              ctx.selected && styles.textSelected,
 | 
			
		||||
            ]}>
 | 
			
		||||
            @{feed.creatorHandle}
 | 
			
		||||
          </Text>
 | 
			
		||||
        </View>
 | 
			
		||||
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            a.justify_center,
 | 
			
		||||
            a.align_center,
 | 
			
		||||
            a.rounded_sm,
 | 
			
		||||
            t.atoms.bg,
 | 
			
		||||
            ctx.selected && styles.checkboxSelected,
 | 
			
		||||
            {
 | 
			
		||||
              width: 28,
 | 
			
		||||
              height: 28,
 | 
			
		||||
            },
 | 
			
		||||
          ]}>
 | 
			
		||||
          {ctx.selected && <Check size="sm" fill={t.palette.white} />}
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <View
 | 
			
		||||
        style={[
 | 
			
		||||
          {
 | 
			
		||||
            opacity: ctx.selected ? 0.3 : 1,
 | 
			
		||||
            borderTopWidth: 1,
 | 
			
		||||
          },
 | 
			
		||||
          a.mt_md,
 | 
			
		||||
          a.w_full,
 | 
			
		||||
          t.name === 'light' ? t.atoms.border : t.atoms.border_contrast,
 | 
			
		||||
          ctx.selected && {
 | 
			
		||||
            borderTopColor: t.palette.primary_200,
 | 
			
		||||
          },
 | 
			
		||||
        ]}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <View style={[a.pt_md]}>
 | 
			
		||||
        <RichText value={feed.description} disableLinks />
 | 
			
		||||
      </View>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function FeedCard({config}: {config: FeedConfig}) {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const {data: feed} = useFeedSourceInfoQuery({uri: config.uri})
 | 
			
		||||
 | 
			
		||||
  return !feed ? (
 | 
			
		||||
    <FeedCardPlaceholder />
 | 
			
		||||
  ) : feed.avatar ? (
 | 
			
		||||
    <Toggle.Item
 | 
			
		||||
      name={feed.uri}
 | 
			
		||||
      label={_(msg`Subscribe to the ${feed.displayName} feed`)}>
 | 
			
		||||
      <FeedCardInner config={config} feed={feed} />
 | 
			
		||||
    </Toggle.Item>
 | 
			
		||||
  ) : null
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function FeedCardPlaceholder({primary}: {primary?: boolean}) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        a.relative,
 | 
			
		||||
        a.w_full,
 | 
			
		||||
        a.p_md,
 | 
			
		||||
        a.rounded_md,
 | 
			
		||||
        a.overflow_hidden,
 | 
			
		||||
        t.atoms.bg_contrast_25,
 | 
			
		||||
      ]}>
 | 
			
		||||
      <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            {
 | 
			
		||||
              width: primary ? 64 : 44,
 | 
			
		||||
              height: primary ? 64 : 44,
 | 
			
		||||
            },
 | 
			
		||||
            a.rounded_sm,
 | 
			
		||||
            a.overflow_hidden,
 | 
			
		||||
            t.atoms.bg_contrast_100,
 | 
			
		||||
          ]}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <View style={[a.pt_2xs, a.flex_grow, a.gap_sm]}>
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              {width: 100, height: primary ? 20 : 16},
 | 
			
		||||
              a.rounded_sm,
 | 
			
		||||
              t.atoms.bg_contrast_100,
 | 
			
		||||
            ]}
 | 
			
		||||
          />
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              {width: 60, height: 12},
 | 
			
		||||
              a.rounded_sm,
 | 
			
		||||
              t.atoms.bg_contrast_100,
 | 
			
		||||
            ]}
 | 
			
		||||
          />
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <View
 | 
			
		||||
        style={[
 | 
			
		||||
          {
 | 
			
		||||
            borderTopWidth: 1,
 | 
			
		||||
          },
 | 
			
		||||
          a.mt_md,
 | 
			
		||||
          a.w_full,
 | 
			
		||||
          t.atoms.border,
 | 
			
		||||
        ]}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <View style={[a.pt_md, a.gap_xs]}>
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            {width: '60%', height: 12},
 | 
			
		||||
            a.rounded_sm,
 | 
			
		||||
            t.atoms.bg_contrast_100,
 | 
			
		||||
          ]}
 | 
			
		||||
        />
 | 
			
		||||
      </View>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										160
									
								
								src/screens/Onboarding/StepAlgoFeeds/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,160 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {atoms as a, tokens, useTheme} from '#/alf'
 | 
			
		||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
 | 
			
		||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 | 
			
		||||
import * as Toggle from '#/components/forms/Toggle'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {Loader} from '#/components/Loader'
 | 
			
		||||
import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
 | 
			
		||||
import {useAnalytics} from '#/lib/analytics/analytics'
 | 
			
		||||
 | 
			
		||||
import {Context} from '#/screens/Onboarding/state'
 | 
			
		||||
import {
 | 
			
		||||
  Title,
 | 
			
		||||
  Description,
 | 
			
		||||
  OnboardingControls,
 | 
			
		||||
} from '#/screens/Onboarding/Layout'
 | 
			
		||||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
 | 
			
		||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
 | 
			
		||||
 | 
			
		||||
export type FeedConfig = {
 | 
			
		||||
  default: boolean
 | 
			
		||||
  uri: string
 | 
			
		||||
  gradient?: typeof tokens.gradients.midnight | typeof tokens.gradients.nordic
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const PRIMARY_FEEDS: FeedConfig[] = [
 | 
			
		||||
  {
 | 
			
		||||
    default: true,
 | 
			
		||||
    uri: 'at://did:plc:wqowuobffl66jv3kpsvo7ak4/app.bsky.feed.generator/the-algorithm',
 | 
			
		||||
    gradient: tokens.gradients.midnight,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    default: false,
 | 
			
		||||
    uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot',
 | 
			
		||||
    gradient: tokens.gradients.midnight,
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const SECONDARY_FEEDS: FeedConfig[] = [
 | 
			
		||||
  {
 | 
			
		||||
    default: false,
 | 
			
		||||
    uri: 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/infreq',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    default: false,
 | 
			
		||||
    uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    default: false,
 | 
			
		||||
    uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/best-of-follows',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    default: false,
 | 
			
		||||
    uri: 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/catch-up',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    default: false,
 | 
			
		||||
    uri: 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/at-bangers',
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
export function StepAlgoFeeds() {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const {track} = useAnalytics()
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const {state, dispatch} = React.useContext(Context)
 | 
			
		||||
  const [primaryFeedUris, setPrimaryFeedUris] = React.useState<string[]>(
 | 
			
		||||
    PRIMARY_FEEDS.map(f => (f.default ? f.uri : '')).filter(Boolean),
 | 
			
		||||
  )
 | 
			
		||||
  const [secondaryFeedUris, setSeconaryFeedUris] = React.useState<string[]>([])
 | 
			
		||||
  const [saving, setSaving] = React.useState(false)
 | 
			
		||||
 | 
			
		||||
  const saveFeeds = React.useCallback(async () => {
 | 
			
		||||
    setSaving(true)
 | 
			
		||||
 | 
			
		||||
    const uris = primaryFeedUris.concat(secondaryFeedUris)
 | 
			
		||||
    dispatch({type: 'setAlgoFeedsStepResults', feedUris: uris})
 | 
			
		||||
 | 
			
		||||
    setSaving(false)
 | 
			
		||||
    dispatch({type: 'next'})
 | 
			
		||||
    track('OnboardingV2:StepAlgoFeeds:End', {
 | 
			
		||||
      selectedPrimaryFeeds: primaryFeedUris,
 | 
			
		||||
      selectedPrimaryFeedsLength: primaryFeedUris.length,
 | 
			
		||||
      selectedSecondaryFeeds: secondaryFeedUris,
 | 
			
		||||
      selectedSecondaryFeedsLength: secondaryFeedUris.length,
 | 
			
		||||
    })
 | 
			
		||||
  }, [primaryFeedUris, secondaryFeedUris, dispatch, track])
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    track('OnboardingV2:StepAlgoFeeds:Start')
 | 
			
		||||
  }, [track])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View style={[a.align_start]}>
 | 
			
		||||
      <IconCircle icon={ListSparkle} style={[a.mb_2xl]} />
 | 
			
		||||
 | 
			
		||||
      <Title>
 | 
			
		||||
        <Trans>Choose your algorithmic feeds</Trans>
 | 
			
		||||
      </Title>
 | 
			
		||||
      <Description>
 | 
			
		||||
        <Trans>
 | 
			
		||||
          Feeds are created by users and can give you entirely new experiences.
 | 
			
		||||
        </Trans>
 | 
			
		||||
      </Description>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.w_full, a.pb_2xl]}>
 | 
			
		||||
        <Toggle.Group
 | 
			
		||||
          values={primaryFeedUris}
 | 
			
		||||
          onChange={setPrimaryFeedUris}
 | 
			
		||||
          label={_(msg`Select your primary algorithmic feeds`)}>
 | 
			
		||||
          <Text
 | 
			
		||||
            style={[a.text_md, a.pt_4xl, a.pb_md, t.atoms.text_contrast_700]}>
 | 
			
		||||
            <Trans>We recommend "For You" by Skygaze:</Trans>
 | 
			
		||||
          </Text>
 | 
			
		||||
          <FeedCard config={PRIMARY_FEEDS[0]} />
 | 
			
		||||
          <Text
 | 
			
		||||
            style={[a.text_md, a.pt_4xl, a.pb_lg, t.atoms.text_contrast_700]}>
 | 
			
		||||
            <Trans>Or you can try our "Discover" algorithm:</Trans>
 | 
			
		||||
          </Text>
 | 
			
		||||
          <FeedCard config={PRIMARY_FEEDS[1]} />
 | 
			
		||||
        </Toggle.Group>
 | 
			
		||||
 | 
			
		||||
        <Toggle.Group
 | 
			
		||||
          values={secondaryFeedUris}
 | 
			
		||||
          onChange={setSeconaryFeedUris}
 | 
			
		||||
          label={_(msg`Select your secondary algorithmic feeds`)}>
 | 
			
		||||
          <Text
 | 
			
		||||
            style={[a.text_md, a.pt_4xl, a.pb_lg, t.atoms.text_contrast_700]}>
 | 
			
		||||
            <Trans>There are many feeds to try:</Trans>
 | 
			
		||||
          </Text>
 | 
			
		||||
          <View style={[a.gap_md]}>
 | 
			
		||||
            {SECONDARY_FEEDS.map(config => (
 | 
			
		||||
              <FeedCard key={config.uri} config={config} />
 | 
			
		||||
            ))}
 | 
			
		||||
          </View>
 | 
			
		||||
        </Toggle.Group>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <OnboardingControls.Portal>
 | 
			
		||||
        <Button
 | 
			
		||||
          disabled={saving}
 | 
			
		||||
          key={state.activeStep} // remove focus state on nav
 | 
			
		||||
          variant="gradient"
 | 
			
		||||
          color="gradient_sky"
 | 
			
		||||
          size="large"
 | 
			
		||||
          label={_(msg`Continue to the next step`)}
 | 
			
		||||
          onPress={saveFeeds}>
 | 
			
		||||
          <ButtonText>
 | 
			
		||||
            <Trans>Continue</Trans>
 | 
			
		||||
          </ButtonText>
 | 
			
		||||
          <ButtonIcon icon={saving ? Loader : ChevronRight} position="right" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </OnboardingControls.Portal>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										158
									
								
								src/screens/Onboarding/StepFinished.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,158 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {logger} from '#/logger'
 | 
			
		||||
import {atoms as a, useTheme} from '#/alf'
 | 
			
		||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
 | 
			
		||||
import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
 | 
			
		||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 | 
			
		||||
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
 | 
			
		||||
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {useOnboardingDispatch} from '#/state/shell'
 | 
			
		||||
import {Loader} from '#/components/Loader'
 | 
			
		||||
import {useSetSaveFeedsMutation} from '#/state/queries/preferences'
 | 
			
		||||
import {getAgent} from '#/state/session'
 | 
			
		||||
import {useAnalytics} from '#/lib/analytics/analytics'
 | 
			
		||||
 | 
			
		||||
import {Context} from '#/screens/Onboarding/state'
 | 
			
		||||
import {
 | 
			
		||||
  Title,
 | 
			
		||||
  Description,
 | 
			
		||||
  OnboardingControls,
 | 
			
		||||
} from '#/screens/Onboarding/Layout'
 | 
			
		||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
 | 
			
		||||
import {
 | 
			
		||||
  bulkWriteFollows,
 | 
			
		||||
  sortPrimaryAlgorithmFeeds,
 | 
			
		||||
} from '#/screens/Onboarding/util'
 | 
			
		||||
 | 
			
		||||
export function StepFinished() {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const {track} = useAnalytics()
 | 
			
		||||
  const {state, dispatch} = React.useContext(Context)
 | 
			
		||||
  const onboardDispatch = useOnboardingDispatch()
 | 
			
		||||
  const [saving, setSaving] = React.useState(false)
 | 
			
		||||
  const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation()
 | 
			
		||||
 | 
			
		||||
  const finishOnboarding = React.useCallback(async () => {
 | 
			
		||||
    setSaving(true)
 | 
			
		||||
 | 
			
		||||
    const {
 | 
			
		||||
      interestsStepResults,
 | 
			
		||||
      suggestedAccountsStepResults,
 | 
			
		||||
      algoFeedsStepResults,
 | 
			
		||||
      topicalFeedsStepResults,
 | 
			
		||||
    } = state
 | 
			
		||||
    const {selectedInterests} = interestsStepResults
 | 
			
		||||
    const selectedFeeds = [
 | 
			
		||||
      ...sortPrimaryAlgorithmFeeds(algoFeedsStepResults.feedUris),
 | 
			
		||||
      ...topicalFeedsStepResults.feedUris,
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await Promise.all([
 | 
			
		||||
        bulkWriteFollows(suggestedAccountsStepResults.accountDids),
 | 
			
		||||
        // these must be serial
 | 
			
		||||
        (async () => {
 | 
			
		||||
          await getAgent().setInterestsPref({tags: selectedInterests})
 | 
			
		||||
          await saveFeeds({
 | 
			
		||||
            saved: selectedFeeds,
 | 
			
		||||
            pinned: selectedFeeds,
 | 
			
		||||
          })
 | 
			
		||||
        })(),
 | 
			
		||||
      ])
 | 
			
		||||
    } catch (e: any) {
 | 
			
		||||
      logger.info(`onboarding: bulk save failed`)
 | 
			
		||||
      logger.error(e)
 | 
			
		||||
      // don't alert the user, just let them into their account
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setSaving(false)
 | 
			
		||||
    dispatch({type: 'finish'})
 | 
			
		||||
    onboardDispatch({type: 'finish'})
 | 
			
		||||
    track('OnboardingV2:StepFinished:End')
 | 
			
		||||
    track('OnboardingV2:Complete')
 | 
			
		||||
  }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track])
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    track('OnboardingV2:StepFinished:Start')
 | 
			
		||||
  }, [track])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View style={[a.align_start]}>
 | 
			
		||||
      <IconCircle icon={Check} style={[a.mb_2xl]} />
 | 
			
		||||
 | 
			
		||||
      <Title>
 | 
			
		||||
        <Trans>You're ready to go!</Trans>
 | 
			
		||||
      </Title>
 | 
			
		||||
      <Description>
 | 
			
		||||
        <Trans>We hope you have a wonderful time. Remember, Bluesky is:</Trans>
 | 
			
		||||
      </Description>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.pt_5xl, a.gap_3xl]}>
 | 
			
		||||
        <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
 | 
			
		||||
          <IconCircle icon={Growth} size="lg" style={{width: 48, height: 48}} />
 | 
			
		||||
          <View style={[a.flex_1, a.gap_xs]}>
 | 
			
		||||
            <Text style={[a.font_bold, a.text_lg]}>
 | 
			
		||||
              <Trans>Public</Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
            <Text
 | 
			
		||||
              style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}>
 | 
			
		||||
              <Trans>
 | 
			
		||||
                Your posts, likes, and blocks are public. Mutes are private.
 | 
			
		||||
              </Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
          </View>
 | 
			
		||||
        </View>
 | 
			
		||||
        <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
 | 
			
		||||
          <IconCircle icon={News} size="lg" style={{width: 48, height: 48}} />
 | 
			
		||||
          <View style={[a.flex_1, a.gap_xs]}>
 | 
			
		||||
            <Text style={[a.font_bold, a.text_lg]}>
 | 
			
		||||
              <Trans>Open</Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
            <Text
 | 
			
		||||
              style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}>
 | 
			
		||||
              <Trans>Never lose access to your followers and data.</Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
          </View>
 | 
			
		||||
        </View>
 | 
			
		||||
        <View style={[a.flex_row, a.align_center, a.w_full, a.gap_lg]}>
 | 
			
		||||
          <IconCircle
 | 
			
		||||
            icon={Trending}
 | 
			
		||||
            size="lg"
 | 
			
		||||
            style={{width: 48, height: 48}}
 | 
			
		||||
          />
 | 
			
		||||
          <View style={[a.flex_1, a.gap_xs]}>
 | 
			
		||||
            <Text style={[a.font_bold, a.text_lg]}>
 | 
			
		||||
              <Trans>Flexible</Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
            <Text
 | 
			
		||||
              style={[t.atoms.text_contrast_500, a.text_md, a.leading_snug]}>
 | 
			
		||||
              <Trans>Choose the algorithms that power your custom feeds.</Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
          </View>
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <OnboardingControls.Portal>
 | 
			
		||||
        <Button
 | 
			
		||||
          disabled={saving}
 | 
			
		||||
          key={state.activeStep} // remove focus state on nav
 | 
			
		||||
          variant="gradient"
 | 
			
		||||
          color="gradient_sky"
 | 
			
		||||
          size="large"
 | 
			
		||||
          label={_(msg`Complete onboarding and start using your account`)}
 | 
			
		||||
          onPress={finishOnboarding}>
 | 
			
		||||
          <ButtonText>
 | 
			
		||||
            {saving ? <Trans>Finalizing</Trans> : <Trans>Let's go!</Trans>}
 | 
			
		||||
          </ButtonText>
 | 
			
		||||
          {saving && <ButtonIcon icon={Loader} position="right" />}
 | 
			
		||||
        </Button>
 | 
			
		||||
      </OnboardingControls.Portal>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										160
									
								
								src/screens/Onboarding/StepFollowingFeed.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,160 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {atoms as a} from '#/alf'
 | 
			
		||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
 | 
			
		||||
import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
 | 
			
		||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {Divider} from '#/components/Divider'
 | 
			
		||||
import * as Toggle from '#/components/forms/Toggle'
 | 
			
		||||
import {useAnalytics} from '#/lib/analytics/analytics'
 | 
			
		||||
 | 
			
		||||
import {Context} from '#/screens/Onboarding/state'
 | 
			
		||||
import {
 | 
			
		||||
  Title,
 | 
			
		||||
  Description,
 | 
			
		||||
  OnboardingControls,
 | 
			
		||||
} from '#/screens/Onboarding/Layout'
 | 
			
		||||
import {
 | 
			
		||||
  usePreferencesQuery,
 | 
			
		||||
  useSetFeedViewPreferencesMutation,
 | 
			
		||||
} from 'state/queries/preferences'
 | 
			
		||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
 | 
			
		||||
 | 
			
		||||
export function StepFollowingFeed() {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const {track} = useAnalytics()
 | 
			
		||||
  const {dispatch} = React.useContext(Context)
 | 
			
		||||
 | 
			
		||||
  const {data: preferences} = usePreferencesQuery()
 | 
			
		||||
  const {mutate: setFeedViewPref, variables} =
 | 
			
		||||
    useSetFeedViewPreferencesMutation()
 | 
			
		||||
 | 
			
		||||
  const showReplies = !(
 | 
			
		||||
    variables?.hideReplies ?? preferences?.feedViewPrefs.hideReplies
 | 
			
		||||
  )
 | 
			
		||||
  const showReposts = !(
 | 
			
		||||
    variables?.hideReposts ?? preferences?.feedViewPrefs.hideReposts
 | 
			
		||||
  )
 | 
			
		||||
  const showQuotes = !(
 | 
			
		||||
    variables?.hideQuotePosts ?? preferences?.feedViewPrefs.hideQuotePosts
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const onContinue = React.useCallback(() => {
 | 
			
		||||
    dispatch({type: 'next'})
 | 
			
		||||
    track('OnboardingV2:StepFollowingFeed:End')
 | 
			
		||||
  }, [track, dispatch])
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    track('OnboardingV2:StepFollowingFeed:Start')
 | 
			
		||||
  }, [track])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    // Hack for now to move the image container up
 | 
			
		||||
    <View style={[a.align_start]}>
 | 
			
		||||
      <IconCircle icon={FilterTimeline} style={[a.mb_2xl]} />
 | 
			
		||||
 | 
			
		||||
      <Title>
 | 
			
		||||
        <Trans>Your default feed is "Following"</Trans>
 | 
			
		||||
      </Title>
 | 
			
		||||
      <Description style={[a.mb_md]}>
 | 
			
		||||
        <Trans>It show posts from the people your follow as they happen.</Trans>
 | 
			
		||||
      </Description>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.w_full]}>
 | 
			
		||||
        <Toggle.Item
 | 
			
		||||
          name="Show Replies" // no need to translate
 | 
			
		||||
          label={_(msg`Show replies in Following feed`)}
 | 
			
		||||
          value={showReplies}
 | 
			
		||||
          onChange={() => {
 | 
			
		||||
            setFeedViewPref({
 | 
			
		||||
              hideReplies: showReplies,
 | 
			
		||||
            })
 | 
			
		||||
          }}>
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              a.flex_row,
 | 
			
		||||
              a.w_full,
 | 
			
		||||
              a.py_lg,
 | 
			
		||||
              a.justify_between,
 | 
			
		||||
              a.align_center,
 | 
			
		||||
            ]}>
 | 
			
		||||
            <Text style={[a.text_md, a.font_bold]}>
 | 
			
		||||
              <Trans>Show replies in Following</Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
            <Toggle.Switch />
 | 
			
		||||
          </View>
 | 
			
		||||
        </Toggle.Item>
 | 
			
		||||
        <Divider />
 | 
			
		||||
        <Toggle.Item
 | 
			
		||||
          name="Show Reposts" // no need to translate
 | 
			
		||||
          label={_(msg`Show re-posts in Following feed`)}
 | 
			
		||||
          value={showReposts}
 | 
			
		||||
          onChange={() => {
 | 
			
		||||
            setFeedViewPref({
 | 
			
		||||
              hideReposts: showReposts,
 | 
			
		||||
            })
 | 
			
		||||
          }}>
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              a.flex_row,
 | 
			
		||||
              a.w_full,
 | 
			
		||||
              a.py_lg,
 | 
			
		||||
              a.justify_between,
 | 
			
		||||
              a.align_center,
 | 
			
		||||
            ]}>
 | 
			
		||||
            <Text style={[a.text_md, a.font_bold]}>
 | 
			
		||||
              <Trans>Show reposts in Following</Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
            <Toggle.Switch />
 | 
			
		||||
          </View>
 | 
			
		||||
        </Toggle.Item>
 | 
			
		||||
        <Divider />
 | 
			
		||||
        <Toggle.Item
 | 
			
		||||
          name="Show Quotes" // no need to translate
 | 
			
		||||
          label={_(msg`Show quote-posts in Following feed`)}
 | 
			
		||||
          value={showQuotes}
 | 
			
		||||
          onChange={() => {
 | 
			
		||||
            setFeedViewPref({
 | 
			
		||||
              hideQuotePosts: showQuotes,
 | 
			
		||||
            })
 | 
			
		||||
          }}>
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              a.flex_row,
 | 
			
		||||
              a.w_full,
 | 
			
		||||
              a.py_lg,
 | 
			
		||||
              a.justify_between,
 | 
			
		||||
              a.align_center,
 | 
			
		||||
            ]}>
 | 
			
		||||
            <Text style={[a.text_md, a.font_bold]}>
 | 
			
		||||
              <Trans>Show quotes in Following</Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
            <Toggle.Switch />
 | 
			
		||||
          </View>
 | 
			
		||||
        </Toggle.Item>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <Description style={[a.mt_lg]}>
 | 
			
		||||
        <Trans>You can change these settings later.</Trans>
 | 
			
		||||
      </Description>
 | 
			
		||||
 | 
			
		||||
      <OnboardingControls.Portal>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="gradient"
 | 
			
		||||
          color="gradient_sky"
 | 
			
		||||
          size="large"
 | 
			
		||||
          label={_(msg`Continue to next step`)}
 | 
			
		||||
          onPress={onContinue}>
 | 
			
		||||
          <ButtonText>
 | 
			
		||||
            <Trans>Continue</Trans>
 | 
			
		||||
          </ButtonText>
 | 
			
		||||
          <ButtonIcon icon={ChevronRight} position="right" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </OnboardingControls.Portal>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										79
									
								
								src/screens/Onboarding/StepInterests/InterestButton.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,79 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View, ViewStyle, TextStyle} from 'react-native'
 | 
			
		||||
 | 
			
		||||
import {useTheme, atoms as a, native} from '#/alf'
 | 
			
		||||
import * as Toggle from '#/components/forms/Toggle'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
 | 
			
		||||
import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data'
 | 
			
		||||
 | 
			
		||||
export function InterestButton({interest}: {interest: string}) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const ctx = Toggle.useItemContext()
 | 
			
		||||
 | 
			
		||||
  const styles = React.useMemo(() => {
 | 
			
		||||
    const hovered: ViewStyle[] = [
 | 
			
		||||
      {
 | 
			
		||||
        backgroundColor:
 | 
			
		||||
          t.name === 'light' ? t.palette.contrast_200 : t.palette.contrast_50,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    const focused: ViewStyle[] = []
 | 
			
		||||
    const pressed: ViewStyle[] = []
 | 
			
		||||
    const selected: ViewStyle[] = [
 | 
			
		||||
      {
 | 
			
		||||
        backgroundColor: t.palette.contrast_900,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    const selectedHover: ViewStyle[] = [
 | 
			
		||||
      {
 | 
			
		||||
        backgroundColor: t.palette.contrast_800,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    const textSelected: TextStyle[] = [
 | 
			
		||||
      {
 | 
			
		||||
        color: t.palette.contrast_100,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      hovered,
 | 
			
		||||
      focused,
 | 
			
		||||
      pressed,
 | 
			
		||||
      selected,
 | 
			
		||||
      selectedHover,
 | 
			
		||||
      textSelected,
 | 
			
		||||
    }
 | 
			
		||||
  }, [t])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        {
 | 
			
		||||
          backgroundColor: t.palette.contrast_100,
 | 
			
		||||
          paddingVertical: 15,
 | 
			
		||||
        },
 | 
			
		||||
        a.rounded_full,
 | 
			
		||||
        a.px_2xl,
 | 
			
		||||
        ctx.hovered ? styles.hovered : {},
 | 
			
		||||
        ctx.focused ? styles.hovered : {},
 | 
			
		||||
        ctx.pressed ? styles.hovered : {},
 | 
			
		||||
        ctx.selected ? styles.selected : {},
 | 
			
		||||
        ctx.selected && (ctx.hovered || ctx.focused || ctx.pressed)
 | 
			
		||||
          ? styles.selectedHover
 | 
			
		||||
          : {},
 | 
			
		||||
      ]}>
 | 
			
		||||
      <Text
 | 
			
		||||
        style={[
 | 
			
		||||
          {
 | 
			
		||||
            color: t.palette.contrast_900,
 | 
			
		||||
          },
 | 
			
		||||
          a.font_bold,
 | 
			
		||||
          native({paddingTop: 2}),
 | 
			
		||||
          ctx.selected ? styles.textSelected : {},
 | 
			
		||||
        ]}>
 | 
			
		||||
        {INTEREST_TO_DISPLAY_NAME[interest]}
 | 
			
		||||
      </Text>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										36
									
								
								src/screens/Onboarding/StepInterests/data.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
export const INTEREST_TO_DISPLAY_NAME: {
 | 
			
		||||
  [key: string]: string
 | 
			
		||||
} = {
 | 
			
		||||
  news: 'News',
 | 
			
		||||
  journalism: 'Journalism',
 | 
			
		||||
  nature: 'Nature',
 | 
			
		||||
  art: 'Art',
 | 
			
		||||
  comics: 'Comics',
 | 
			
		||||
  writers: 'Writers',
 | 
			
		||||
  culture: 'Culture',
 | 
			
		||||
  sports: 'Sports',
 | 
			
		||||
  pets: 'Pets',
 | 
			
		||||
  animals: 'Animals',
 | 
			
		||||
  books: 'Books',
 | 
			
		||||
  education: 'Education',
 | 
			
		||||
  climate: 'Climate',
 | 
			
		||||
  science: 'Science',
 | 
			
		||||
  politics: 'Politics',
 | 
			
		||||
  fitness: 'Fitness',
 | 
			
		||||
  tech: 'Tech',
 | 
			
		||||
  dev: 'Software Dev',
 | 
			
		||||
  comedy: 'Comedy',
 | 
			
		||||
  gaming: 'Video Games',
 | 
			
		||||
  food: 'Food',
 | 
			
		||||
  cooking: 'Cooking',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type ApiResponseMap = {
 | 
			
		||||
  interests: string[]
 | 
			
		||||
  suggestedAccountDids: {
 | 
			
		||||
    [key: string]: string[]
 | 
			
		||||
  }
 | 
			
		||||
  suggestedFeedUris: {
 | 
			
		||||
    [key: string]: string[]
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										260
									
								
								src/screens/Onboarding/StepInterests/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,260 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
import {useQuery} from '@tanstack/react-query'
 | 
			
		||||
 | 
			
		||||
import {logger} from '#/logger'
 | 
			
		||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 | 
			
		||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
 | 
			
		||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
 | 
			
		||||
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
 | 
			
		||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
 | 
			
		||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 | 
			
		||||
import {Loader} from '#/components/Loader'
 | 
			
		||||
import * as Toggle from '#/components/forms/Toggle'
 | 
			
		||||
import {getAgent} from '#/state/session'
 | 
			
		||||
import {useAnalytics} from '#/lib/analytics/analytics'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {useOnboardingDispatch} from '#/state/shell'
 | 
			
		||||
 | 
			
		||||
import {Context} from '#/screens/Onboarding/state'
 | 
			
		||||
import {
 | 
			
		||||
  Title,
 | 
			
		||||
  Description,
 | 
			
		||||
  OnboardingControls,
 | 
			
		||||
} from '#/screens/Onboarding/Layout'
 | 
			
		||||
import {
 | 
			
		||||
  ApiResponseMap,
 | 
			
		||||
  INTEREST_TO_DISPLAY_NAME,
 | 
			
		||||
} from '#/screens/Onboarding/StepInterests/data'
 | 
			
		||||
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
 | 
			
		||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
 | 
			
		||||
 | 
			
		||||
export function StepInterests() {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const {track} = useAnalytics()
 | 
			
		||||
  const {gtMobile} = useBreakpoints()
 | 
			
		||||
  const {state, dispatch} = React.useContext(Context)
 | 
			
		||||
  const [saving, setSaving] = React.useState(false)
 | 
			
		||||
  const [interests, setInterests] = React.useState<string[]>(
 | 
			
		||||
    state.interestsStepResults.selectedInterests.map(i => i),
 | 
			
		||||
  )
 | 
			
		||||
  const onboardDispatch = useOnboardingDispatch()
 | 
			
		||||
  const {isLoading, isError, error, data, refetch, isFetching} = useQuery({
 | 
			
		||||
    queryKey: ['interests'],
 | 
			
		||||
    queryFn: async () => {
 | 
			
		||||
      try {
 | 
			
		||||
        const {data} =
 | 
			
		||||
          await getAgent().app.bsky.unspecced.getTaggedSuggestions()
 | 
			
		||||
        return data.suggestions.reduce(
 | 
			
		||||
          (agg, s) => {
 | 
			
		||||
            const {tag, subject, subjectType} = s
 | 
			
		||||
            const isDefault = tag === 'default'
 | 
			
		||||
 | 
			
		||||
            if (!agg.interests.includes(tag) && !isDefault) {
 | 
			
		||||
              agg.interests.push(tag)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (subjectType === 'user') {
 | 
			
		||||
              agg.suggestedAccountDids[tag] =
 | 
			
		||||
                agg.suggestedAccountDids[tag] || []
 | 
			
		||||
              agg.suggestedAccountDids[tag].push(subject)
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (subjectType === 'feed') {
 | 
			
		||||
              // agg all feeds into defaults
 | 
			
		||||
              if (isDefault) {
 | 
			
		||||
                agg.suggestedFeedUris[tag] = agg.suggestedFeedUris[tag] || []
 | 
			
		||||
              } else {
 | 
			
		||||
                agg.suggestedFeedUris[tag] = agg.suggestedFeedUris[tag] || []
 | 
			
		||||
                agg.suggestedFeedUris[tag].push(subject)
 | 
			
		||||
                agg.suggestedFeedUris.default.push(subject)
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return agg
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            interests: [],
 | 
			
		||||
            suggestedAccountDids: {},
 | 
			
		||||
            suggestedFeedUris: {},
 | 
			
		||||
          } as ApiResponseMap,
 | 
			
		||||
        )
 | 
			
		||||
      } catch (e: any) {
 | 
			
		||||
        logger.info(
 | 
			
		||||
          `onboarding: getTaggedSuggestions fetch or processing failed`,
 | 
			
		||||
        )
 | 
			
		||||
        logger.error(e)
 | 
			
		||||
        track('OnboardingV2:StepInterests:Error')
 | 
			
		||||
 | 
			
		||||
        throw new Error(`a network error occurred`)
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const saveInterests = React.useCallback(async () => {
 | 
			
		||||
    setSaving(true)
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      setSaving(false)
 | 
			
		||||
      dispatch({
 | 
			
		||||
        type: 'setInterestsStepResults',
 | 
			
		||||
        apiResponse: data!,
 | 
			
		||||
        selectedInterests: interests,
 | 
			
		||||
      })
 | 
			
		||||
      dispatch({type: 'next'})
 | 
			
		||||
 | 
			
		||||
      track('OnboardingV2:StepInterests:End', {
 | 
			
		||||
        selectedInterests: interests,
 | 
			
		||||
        selectedInterestsLength: interests.length,
 | 
			
		||||
      })
 | 
			
		||||
    } catch (e: any) {
 | 
			
		||||
      logger.info(`onboading: error saving interests`)
 | 
			
		||||
      logger.error(e)
 | 
			
		||||
    }
 | 
			
		||||
  }, [interests, data, setSaving, dispatch, track])
 | 
			
		||||
 | 
			
		||||
  const skipOnboarding = React.useCallback(() => {
 | 
			
		||||
    onboardDispatch({type: 'finish'})
 | 
			
		||||
    dispatch({type: 'finish'})
 | 
			
		||||
    track('OnboardingV2:Skip')
 | 
			
		||||
  }, [onboardDispatch, dispatch, track])
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    track('OnboardingV2:Begin')
 | 
			
		||||
    track('OnboardingV2:StepInterests:Start')
 | 
			
		||||
  }, [track])
 | 
			
		||||
 | 
			
		||||
  const title = isError ? (
 | 
			
		||||
    <Trans>Oh no! Something went wrong.</Trans>
 | 
			
		||||
  ) : (
 | 
			
		||||
    <Trans>What are your interests?</Trans>
 | 
			
		||||
  )
 | 
			
		||||
  const description = isError ? (
 | 
			
		||||
    <Trans>
 | 
			
		||||
      We weren't able to connect. Please try again to continue setting up your
 | 
			
		||||
      account. If it continues to fail, you can skip this flow.
 | 
			
		||||
    </Trans>
 | 
			
		||||
  ) : (
 | 
			
		||||
    <Trans>We'll use this to help customize your experience.</Trans>
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View style={[a.align_start]}>
 | 
			
		||||
      <IconCircle
 | 
			
		||||
        icon={isError ? EmojiSad : Hashtag}
 | 
			
		||||
        style={[
 | 
			
		||||
          a.mb_2xl,
 | 
			
		||||
          isError
 | 
			
		||||
            ? {
 | 
			
		||||
                backgroundColor: t.palette.negative_50,
 | 
			
		||||
              }
 | 
			
		||||
            : {},
 | 
			
		||||
        ]}
 | 
			
		||||
        iconStyle={[
 | 
			
		||||
          isError
 | 
			
		||||
            ? {
 | 
			
		||||
                color: t.palette.negative_900,
 | 
			
		||||
              }
 | 
			
		||||
            : {},
 | 
			
		||||
        ]}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <Title>{title}</Title>
 | 
			
		||||
      <Description>{description}</Description>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.w_full, a.pt_2xl]}>
 | 
			
		||||
        {isLoading ? (
 | 
			
		||||
          <Loader size="xl" />
 | 
			
		||||
        ) : isError || !data ? (
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              a.w_full,
 | 
			
		||||
              a.p_lg,
 | 
			
		||||
              a.rounded_md,
 | 
			
		||||
              {
 | 
			
		||||
                backgroundColor: t.palette.negative_50,
 | 
			
		||||
              },
 | 
			
		||||
            ]}>
 | 
			
		||||
            <Text style={[a.text_md]}>
 | 
			
		||||
              <Text
 | 
			
		||||
                style={[
 | 
			
		||||
                  a.text_md,
 | 
			
		||||
                  a.font_bold,
 | 
			
		||||
                  {
 | 
			
		||||
                    color: t.palette.negative_900,
 | 
			
		||||
                  },
 | 
			
		||||
                ]}>
 | 
			
		||||
                Error:{' '}
 | 
			
		||||
              </Text>
 | 
			
		||||
              {error?.message || 'an unknown error occurred'}
 | 
			
		||||
            </Text>
 | 
			
		||||
          </View>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Toggle.Group
 | 
			
		||||
            values={interests}
 | 
			
		||||
            onChange={setInterests}
 | 
			
		||||
            label={_(msg`Select your interests from the options below`)}>
 | 
			
		||||
            <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
 | 
			
		||||
              {data.interests.map(interest => (
 | 
			
		||||
                <Toggle.Item
 | 
			
		||||
                  key={interest}
 | 
			
		||||
                  name={interest}
 | 
			
		||||
                  label={INTEREST_TO_DISPLAY_NAME[interest]}>
 | 
			
		||||
                  <InterestButton interest={interest} />
 | 
			
		||||
                </Toggle.Item>
 | 
			
		||||
              ))}
 | 
			
		||||
            </View>
 | 
			
		||||
          </Toggle.Group>
 | 
			
		||||
        )}
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <OnboardingControls.Portal>
 | 
			
		||||
        {isError ? (
 | 
			
		||||
          <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
 | 
			
		||||
            <Button
 | 
			
		||||
              disabled={isFetching}
 | 
			
		||||
              variant="solid"
 | 
			
		||||
              color="secondary"
 | 
			
		||||
              size="large"
 | 
			
		||||
              label={_(msg`Retry`)}
 | 
			
		||||
              onPress={() => refetch()}>
 | 
			
		||||
              <ButtonText>
 | 
			
		||||
                <Trans>Retry</Trans>
 | 
			
		||||
              </ButtonText>
 | 
			
		||||
              <ButtonIcon icon={ArrowRotateCounterClockwise} position="right" />
 | 
			
		||||
            </Button>
 | 
			
		||||
            <Button
 | 
			
		||||
              variant="outline"
 | 
			
		||||
              color="secondary"
 | 
			
		||||
              size="large"
 | 
			
		||||
              label={_(msg`Skip this flow`)}
 | 
			
		||||
              onPress={skipOnboarding}>
 | 
			
		||||
              <ButtonText>
 | 
			
		||||
                <Trans>Skip</Trans>
 | 
			
		||||
              </ButtonText>
 | 
			
		||||
            </Button>
 | 
			
		||||
          </View>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Button
 | 
			
		||||
            disabled={saving || !data}
 | 
			
		||||
            variant="gradient"
 | 
			
		||||
            color="gradient_sky"
 | 
			
		||||
            size="large"
 | 
			
		||||
            label={_(msg`Continue to next step`)}
 | 
			
		||||
            onPress={saveInterests}>
 | 
			
		||||
            <ButtonText>
 | 
			
		||||
              <Trans>Continue</Trans>
 | 
			
		||||
            </ButtonText>
 | 
			
		||||
            <ButtonIcon
 | 
			
		||||
              icon={saving ? Loader : ChevronRight}
 | 
			
		||||
              position="right"
 | 
			
		||||
            />
 | 
			
		||||
          </Button>
 | 
			
		||||
        )}
 | 
			
		||||
      </OnboardingControls.Portal>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,135 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {isIOS} from '#/platform/detection'
 | 
			
		||||
import * as Toast from '#/view/com/util/Toast'
 | 
			
		||||
import {atoms as a, useTheme} from '#/alf'
 | 
			
		||||
import {
 | 
			
		||||
  usePreferencesQuery,
 | 
			
		||||
  usePreferencesSetAdultContentMutation,
 | 
			
		||||
} from '#/state/queries/preferences'
 | 
			
		||||
import {logger} from '#/logger'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {InlineLink} from '#/components/Link'
 | 
			
		||||
import * as Toggle from '#/components/forms/Toggle'
 | 
			
		||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 | 
			
		||||
 | 
			
		||||
function Card({children}: React.PropsWithChildren<{}>) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        a.w_full,
 | 
			
		||||
        a.flex_row,
 | 
			
		||||
        a.align_center,
 | 
			
		||||
        a.gap_sm,
 | 
			
		||||
        a.px_lg,
 | 
			
		||||
        a.py_md,
 | 
			
		||||
        a.rounded_sm,
 | 
			
		||||
        a.mb_md,
 | 
			
		||||
        t.atoms.bg_contrast_50,
 | 
			
		||||
      ]}>
 | 
			
		||||
      {children}
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function AdultContentEnabledPref() {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
 | 
			
		||||
  // Reuse logic here form ContentFilteringSettings.tsx
 | 
			
		||||
  const {data: preferences} = usePreferencesQuery()
 | 
			
		||||
  const {mutate, variables} = usePreferencesSetAdultContentMutation()
 | 
			
		||||
 | 
			
		||||
  const onToggleAdultContent = React.useCallback(async () => {
 | 
			
		||||
    if (isIOS) return
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      mutate({
 | 
			
		||||
        enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
 | 
			
		||||
      })
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      Toast.show(
 | 
			
		||||
        _(msg`There was an issue syncing your preferences with the server`),
 | 
			
		||||
      )
 | 
			
		||||
      logger.error('Failed to update preferences with server', {error: e})
 | 
			
		||||
    }
 | 
			
		||||
  }, [variables, preferences, mutate, _])
 | 
			
		||||
 | 
			
		||||
  if (!preferences) return null
 | 
			
		||||
 | 
			
		||||
  if (isIOS) {
 | 
			
		||||
    if (preferences?.adultContentEnabled === true) {
 | 
			
		||||
      return null
 | 
			
		||||
    } else {
 | 
			
		||||
      return (
 | 
			
		||||
        <Card>
 | 
			
		||||
          <CircleInfo size="sm" fill={t.palette.contrast_500} />
 | 
			
		||||
          <Text
 | 
			
		||||
            style={[
 | 
			
		||||
              a.flex_1,
 | 
			
		||||
              t.atoms.text_contrast_700,
 | 
			
		||||
              a.leading_snug,
 | 
			
		||||
              {paddingTop: 1},
 | 
			
		||||
            ]}>
 | 
			
		||||
            <Trans>
 | 
			
		||||
              Adult content can only be enabled via the Web at{' '}
 | 
			
		||||
              <InlineLink style={[a.leading_snug]} to="https://bsky.app">
 | 
			
		||||
                bsky.app
 | 
			
		||||
              </InlineLink>
 | 
			
		||||
              .
 | 
			
		||||
            </Trans>
 | 
			
		||||
          </Text>
 | 
			
		||||
        </Card>
 | 
			
		||||
      )
 | 
			
		||||
    }
 | 
			
		||||
  } else {
 | 
			
		||||
    if (preferences?.userAge) {
 | 
			
		||||
      if (preferences.userAge >= 18) {
 | 
			
		||||
        return (
 | 
			
		||||
          <View style={[a.w_full]}>
 | 
			
		||||
            <Toggle.Item
 | 
			
		||||
              name={_(msg`Enable adult content in your feeds`)}
 | 
			
		||||
              label={_(msg`Enable adult content in your feeds`)}
 | 
			
		||||
              value={variables?.enabled ?? preferences?.adultContentEnabled}
 | 
			
		||||
              onChange={onToggleAdultContent}>
 | 
			
		||||
              <View
 | 
			
		||||
                style={[
 | 
			
		||||
                  a.flex_row,
 | 
			
		||||
                  a.w_full,
 | 
			
		||||
                  a.justify_between,
 | 
			
		||||
                  a.align_center,
 | 
			
		||||
                  a.py_md,
 | 
			
		||||
                ]}>
 | 
			
		||||
                <Text style={[a.font_bold]}>Enable Adult Content</Text>
 | 
			
		||||
                <Toggle.Switch />
 | 
			
		||||
              </View>
 | 
			
		||||
            </Toggle.Item>
 | 
			
		||||
          </View>
 | 
			
		||||
        )
 | 
			
		||||
      } else {
 | 
			
		||||
        return (
 | 
			
		||||
          <Card>
 | 
			
		||||
            <CircleInfo size="sm" fill={t.palette.contrast_500} />
 | 
			
		||||
            <Text
 | 
			
		||||
              style={[
 | 
			
		||||
                a.flex_1,
 | 
			
		||||
                t.atoms.text_contrast_700,
 | 
			
		||||
                a.leading_snug,
 | 
			
		||||
                {paddingTop: 1},
 | 
			
		||||
              ]}>
 | 
			
		||||
              <Trans>
 | 
			
		||||
                You must be 18 years or older to enable adult content
 | 
			
		||||
              </Trans>
 | 
			
		||||
            </Text>
 | 
			
		||||
          </Card>
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										85
									
								
								src/screens/Onboarding/StepModeration/ModerationOption.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,85 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {LabelPreference} from '@atproto/api'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  CONFIGURABLE_LABEL_GROUPS,
 | 
			
		||||
  ConfigurableLabelGroup,
 | 
			
		||||
  usePreferencesQuery,
 | 
			
		||||
  usePreferencesSetContentLabelMutation,
 | 
			
		||||
} from '#/state/queries/preferences'
 | 
			
		||||
import {atoms as a, useTheme} from '#/alf'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import * as ToggleButton from '#/components/forms/ToggleButton'
 | 
			
		||||
 | 
			
		||||
export function ModerationOption({
 | 
			
		||||
  labelGroup,
 | 
			
		||||
}: {
 | 
			
		||||
  labelGroup: ConfigurableLabelGroup
 | 
			
		||||
}) {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup]
 | 
			
		||||
  const {data: preferences} = usePreferencesQuery()
 | 
			
		||||
  const {mutate, variables} = usePreferencesSetContentLabelMutation()
 | 
			
		||||
  const visibility =
 | 
			
		||||
    variables?.visibility ?? preferences?.contentLabels?.[labelGroup]
 | 
			
		||||
 | 
			
		||||
  const onChange = React.useCallback(
 | 
			
		||||
    (vis: string[]) => {
 | 
			
		||||
      mutate({labelGroup, visibility: vis[0] as LabelPreference})
 | 
			
		||||
    },
 | 
			
		||||
    [mutate, labelGroup],
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  const labels = {
 | 
			
		||||
    hide: _(msg`Hide`),
 | 
			
		||||
    warn: _(msg`Warn`),
 | 
			
		||||
    show: _(msg`Show`),
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        a.flex_row,
 | 
			
		||||
        a.justify_between,
 | 
			
		||||
        a.gap_sm,
 | 
			
		||||
        a.py_xs,
 | 
			
		||||
        a.px_xs,
 | 
			
		||||
        a.align_center,
 | 
			
		||||
      ]}>
 | 
			
		||||
      <View style={[a.gap_xs, {width: '50%'}]}>
 | 
			
		||||
        <Text style={[a.font_bold]}>{groupInfo.title}</Text>
 | 
			
		||||
        <Text style={[t.atoms.text_contrast_700, a.leading_snug]}>
 | 
			
		||||
          {groupInfo.subtitle}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </View>
 | 
			
		||||
      <View style={[a.justify_center, {minHeight: 35}]}>
 | 
			
		||||
        {!preferences?.adultContentEnabled && groupInfo.isAdultImagery ? (
 | 
			
		||||
          <View style={[a.justify_center, {minHeight: 40}]}>
 | 
			
		||||
            <Text style={[a.font_bold]}>{labels.hide}</Text>
 | 
			
		||||
          </View>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <ToggleButton.Group
 | 
			
		||||
            label={_(
 | 
			
		||||
              msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`,
 | 
			
		||||
            )}
 | 
			
		||||
            values={[visibility ?? 'hide']}
 | 
			
		||||
            onChange={onChange}>
 | 
			
		||||
            <ToggleButton.Button name="hide" label={labels.hide}>
 | 
			
		||||
              {labels.hide}
 | 
			
		||||
            </ToggleButton.Button>
 | 
			
		||||
            <ToggleButton.Button name="warn" label={labels.warn}>
 | 
			
		||||
              {labels.warn}
 | 
			
		||||
            </ToggleButton.Button>
 | 
			
		||||
            <ToggleButton.Button name="ignore" label={labels.show}>
 | 
			
		||||
              {labels.show}
 | 
			
		||||
            </ToggleButton.Button>
 | 
			
		||||
          </ToggleButton.Group>
 | 
			
		||||
        )}
 | 
			
		||||
      </View>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										91
									
								
								src/screens/Onboarding/StepModeration/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,91 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {atoms as a} from '#/alf'
 | 
			
		||||
import {configurableLabelGroups} from 'state/queries/preferences'
 | 
			
		||||
import {Divider} from '#/components/Divider'
 | 
			
		||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 | 
			
		||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
 | 
			
		||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
 | 
			
		||||
import {usePreferencesQuery} from '#/state/queries/preferences'
 | 
			
		||||
import {Loader} from '#/components/Loader'
 | 
			
		||||
import {useAnalytics} from '#/lib/analytics/analytics'
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  Description,
 | 
			
		||||
  OnboardingControls,
 | 
			
		||||
  Title,
 | 
			
		||||
} from '#/screens/Onboarding/Layout'
 | 
			
		||||
import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
 | 
			
		||||
import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
 | 
			
		||||
import {Context} from '#/screens/Onboarding/state'
 | 
			
		||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
 | 
			
		||||
 | 
			
		||||
export function StepModeration() {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const {track} = useAnalytics()
 | 
			
		||||
  const {state, dispatch} = React.useContext(Context)
 | 
			
		||||
  const {data: preferences} = usePreferencesQuery()
 | 
			
		||||
 | 
			
		||||
  const onContinue = React.useCallback(() => {
 | 
			
		||||
    dispatch({type: 'next'})
 | 
			
		||||
    track('OnboardingV2:StepModeration:End')
 | 
			
		||||
  }, [track, dispatch])
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    track('OnboardingV2:StepModeration:Start')
 | 
			
		||||
  }, [track])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View style={[a.align_start]}>
 | 
			
		||||
      <IconCircle icon={EyeSlash} style={[a.mb_2xl]} />
 | 
			
		||||
 | 
			
		||||
      <Title>
 | 
			
		||||
        <Trans>You are in control</Trans>
 | 
			
		||||
      </Title>
 | 
			
		||||
      <Description style={[a.mb_xl]}>
 | 
			
		||||
        <Trans>
 | 
			
		||||
          Select the types of content that you want to see (or not see), and
 | 
			
		||||
          we'll handle the rest.
 | 
			
		||||
        </Trans>
 | 
			
		||||
      </Description>
 | 
			
		||||
 | 
			
		||||
      {!preferences ? (
 | 
			
		||||
        <View style={[a.pt_md]}>
 | 
			
		||||
          <Loader size="xl" />
 | 
			
		||||
        </View>
 | 
			
		||||
      ) : (
 | 
			
		||||
        <>
 | 
			
		||||
          <AdultContentEnabledPref />
 | 
			
		||||
 | 
			
		||||
          <View style={[a.gap_sm, a.w_full]}>
 | 
			
		||||
            {configurableLabelGroups.map((g, index) => (
 | 
			
		||||
              <React.Fragment key={index}>
 | 
			
		||||
                {index === 0 && <Divider />}
 | 
			
		||||
                <ModerationOption labelGroup={g} />
 | 
			
		||||
                <Divider />
 | 
			
		||||
              </React.Fragment>
 | 
			
		||||
            ))}
 | 
			
		||||
          </View>
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <OnboardingControls.Portal>
 | 
			
		||||
        <Button
 | 
			
		||||
          key={state.activeStep} // remove focus state on nav
 | 
			
		||||
          variant="gradient"
 | 
			
		||||
          color="gradient_sky"
 | 
			
		||||
          size="large"
 | 
			
		||||
          label={_(msg`Continue to next step`)}
 | 
			
		||||
          onPress={onContinue}>
 | 
			
		||||
          <ButtonText>
 | 
			
		||||
            <Trans>Continue</Trans>
 | 
			
		||||
          </ButtonText>
 | 
			
		||||
          <ButtonIcon icon={ChevronRight} position="right" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </OnboardingControls.Portal>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,188 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View, ViewStyle} from 'react-native'
 | 
			
		||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
 | 
			
		||||
 | 
			
		||||
import {useTheme, atoms as a, flatten} from '#/alf'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {useItemContext} from '#/components/forms/Toggle'
 | 
			
		||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 | 
			
		||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
 | 
			
		||||
import {useModerationOpts} from '#/state/queries/preferences'
 | 
			
		||||
import {RichText} from '#/components/RichText'
 | 
			
		||||
 | 
			
		||||
export function SuggestedAccountCard({
 | 
			
		||||
  profile,
 | 
			
		||||
  moderationOpts,
 | 
			
		||||
}: {
 | 
			
		||||
  profile: AppBskyActorDefs.ProfileViewDetailed
 | 
			
		||||
  moderationOpts: ReturnType<typeof useModerationOpts>
 | 
			
		||||
}) {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  const ctx = useItemContext()
 | 
			
		||||
  const moderation = moderateProfile(profile, moderationOpts!)
 | 
			
		||||
 | 
			
		||||
  const styles = React.useMemo(() => {
 | 
			
		||||
    const light = t.name === 'light'
 | 
			
		||||
    const base: ViewStyle[] = [t.atoms.bg_contrast_50]
 | 
			
		||||
    const hover: ViewStyle[] = [t.atoms.bg_contrast_25]
 | 
			
		||||
    const selected: ViewStyle[] = [
 | 
			
		||||
      {
 | 
			
		||||
        backgroundColor: light ? t.palette.primary_50 : t.palette.primary_950,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    const selectedHover: ViewStyle[] = [
 | 
			
		||||
      {
 | 
			
		||||
        backgroundColor: light ? t.palette.primary_25 : t.palette.primary_975,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    const checkboxBase: ViewStyle[] = [t.atoms.bg]
 | 
			
		||||
    const checkboxSelected: ViewStyle[] = [
 | 
			
		||||
      {
 | 
			
		||||
        backgroundColor: t.palette.primary_500,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    const avatarBase: ViewStyle[] = [t.atoms.bg_contrast_100]
 | 
			
		||||
    const avatarSelected: ViewStyle[] = [
 | 
			
		||||
      {
 | 
			
		||||
        backgroundColor: light ? t.palette.primary_100 : t.palette.primary_900,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      base,
 | 
			
		||||
      hover: flatten(hover),
 | 
			
		||||
      selected: flatten(selected),
 | 
			
		||||
      selectedHover: flatten(selectedHover),
 | 
			
		||||
      checkboxBase: flatten(checkboxBase),
 | 
			
		||||
      checkboxSelected: flatten(checkboxSelected),
 | 
			
		||||
      avatarBase: flatten(avatarBase),
 | 
			
		||||
      avatarSelected: flatten(avatarSelected),
 | 
			
		||||
    }
 | 
			
		||||
  }, [t])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        a.w_full,
 | 
			
		||||
        a.p_md,
 | 
			
		||||
        a.pr_lg,
 | 
			
		||||
        a.gap_md,
 | 
			
		||||
        a.rounded_md,
 | 
			
		||||
        styles.base,
 | 
			
		||||
        (ctx.hovered || ctx.focused || ctx.pressed) && styles.hover,
 | 
			
		||||
        ctx.selected && styles.selected,
 | 
			
		||||
        ctx.selected &&
 | 
			
		||||
          (ctx.hovered || ctx.focused || ctx.pressed) &&
 | 
			
		||||
          styles.selectedHover,
 | 
			
		||||
      ]}>
 | 
			
		||||
      <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
 | 
			
		||||
        <View style={[a.flex_row, a.flex_1, a.align_center, a.gap_md]}>
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              {width: 48, height: 48},
 | 
			
		||||
              a.relative,
 | 
			
		||||
              a.rounded_full,
 | 
			
		||||
              styles.avatarBase,
 | 
			
		||||
              ctx.selected && styles.avatarSelected,
 | 
			
		||||
            ]}>
 | 
			
		||||
            <UserAvatar
 | 
			
		||||
              size={48}
 | 
			
		||||
              avatar={profile.avatar}
 | 
			
		||||
              moderation={moderation.avatar}
 | 
			
		||||
            />
 | 
			
		||||
          </View>
 | 
			
		||||
          <View style={[a.flex_1]}>
 | 
			
		||||
            <Text style={[a.font_bold, a.text_md, a.pb_xs]} numberOfLines={1}>
 | 
			
		||||
              {profile.displayName}
 | 
			
		||||
            </Text>
 | 
			
		||||
            <Text style={[t.atoms.text_contrast_600]}>{profile.handle}</Text>
 | 
			
		||||
          </View>
 | 
			
		||||
        </View>
 | 
			
		||||
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            a.justify_center,
 | 
			
		||||
            a.align_center,
 | 
			
		||||
            a.rounded_sm,
 | 
			
		||||
            styles.checkboxBase,
 | 
			
		||||
            ctx.selected && styles.checkboxSelected,
 | 
			
		||||
            {
 | 
			
		||||
              width: 28,
 | 
			
		||||
              height: 28,
 | 
			
		||||
            },
 | 
			
		||||
          ]}>
 | 
			
		||||
          {ctx.selected && <Check size="sm" fill={t.palette.white} />}
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      {profile.description && (
 | 
			
		||||
        <>
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              {
 | 
			
		||||
                opacity: ctx.selected ? 0.3 : 1,
 | 
			
		||||
                borderTopWidth: 1,
 | 
			
		||||
              },
 | 
			
		||||
              a.w_full,
 | 
			
		||||
              t.name === 'light' ? t.atoms.border : t.atoms.border_contrast,
 | 
			
		||||
              ctx.selected && {
 | 
			
		||||
                borderTopColor: t.palette.primary_200,
 | 
			
		||||
              },
 | 
			
		||||
            ]}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <RichText
 | 
			
		||||
            value={profile.description}
 | 
			
		||||
            disableLinks
 | 
			
		||||
            numberOfLines={2}
 | 
			
		||||
          />
 | 
			
		||||
        </>
 | 
			
		||||
      )}
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function SuggestedAccountCardPlaceholder() {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  return (
 | 
			
		||||
    <View
 | 
			
		||||
      style={[
 | 
			
		||||
        a.w_full,
 | 
			
		||||
        a.flex_row,
 | 
			
		||||
        a.justify_between,
 | 
			
		||||
        a.align_center,
 | 
			
		||||
        a.p_md,
 | 
			
		||||
        a.pr_lg,
 | 
			
		||||
        a.gap_xl,
 | 
			
		||||
        a.rounded_md,
 | 
			
		||||
        t.atoms.bg_contrast_25,
 | 
			
		||||
      ]}>
 | 
			
		||||
      <View style={[a.flex_row, a.align_center, a.gap_md]}>
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            {width: 48, height: 48},
 | 
			
		||||
            a.relative,
 | 
			
		||||
            a.rounded_full,
 | 
			
		||||
            t.atoms.bg_contrast_100,
 | 
			
		||||
          ]}
 | 
			
		||||
        />
 | 
			
		||||
        <View style={[a.gap_xs]}>
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              {width: 100, height: 16},
 | 
			
		||||
              a.rounded_sm,
 | 
			
		||||
              t.atoms.bg_contrast_100,
 | 
			
		||||
            ]}
 | 
			
		||||
          />
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              {width: 60, height: 12},
 | 
			
		||||
              a.rounded_sm,
 | 
			
		||||
              t.atoms.bg_contrast_100,
 | 
			
		||||
            ]}
 | 
			
		||||
          />
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										198
									
								
								src/screens/Onboarding/StepSuggestedAccounts/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,198 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {AppBskyActorDefs} from '@atproto/api'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {atoms as a, useBreakpoints} from '#/alf'
 | 
			
		||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 | 
			
		||||
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
 | 
			
		||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 | 
			
		||||
import {Text} from '#/components/Typography'
 | 
			
		||||
import {useProfilesQuery} from '#/state/queries/profile'
 | 
			
		||||
import {Loader} from '#/components/Loader'
 | 
			
		||||
import * as Toggle from '#/components/forms/Toggle'
 | 
			
		||||
import {useModerationOpts} from '#/state/queries/preferences'
 | 
			
		||||
import {useAnalytics} from '#/lib/analytics/analytics'
 | 
			
		||||
 | 
			
		||||
import {Context} from '#/screens/Onboarding/state'
 | 
			
		||||
import {
 | 
			
		||||
  Title,
 | 
			
		||||
  Description,
 | 
			
		||||
  OnboardingControls,
 | 
			
		||||
} from '#/screens/Onboarding/Layout'
 | 
			
		||||
import {
 | 
			
		||||
  SuggestedAccountCard,
 | 
			
		||||
  SuggestedAccountCardPlaceholder,
 | 
			
		||||
} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
 | 
			
		||||
import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data'
 | 
			
		||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
 | 
			
		||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
 | 
			
		||||
 | 
			
		||||
export function Inner({
 | 
			
		||||
  profiles,
 | 
			
		||||
  onSelect,
 | 
			
		||||
  moderationOpts,
 | 
			
		||||
}: {
 | 
			
		||||
  profiles: AppBskyActorDefs.ProfileViewDetailed[]
 | 
			
		||||
  onSelect: (dids: string[]) => void
 | 
			
		||||
  moderationOpts: ReturnType<typeof useModerationOpts>
 | 
			
		||||
}) {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const [dids, setDids] = React.useState<string[]>(profiles.map(p => p.did))
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    onSelect(dids)
 | 
			
		||||
  }, [dids, onSelect])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Toggle.Group
 | 
			
		||||
      values={dids}
 | 
			
		||||
      onChange={setDids}
 | 
			
		||||
      label={_(msg`Select some accounts below to follow`)}>
 | 
			
		||||
      <View style={[a.gap_md]}>
 | 
			
		||||
        {profiles.map(profile => (
 | 
			
		||||
          <Toggle.Item
 | 
			
		||||
            key={profile.did}
 | 
			
		||||
            name={profile.did}
 | 
			
		||||
            label={_(msg`Follow ${profile.handle}`)}>
 | 
			
		||||
            <SuggestedAccountCard
 | 
			
		||||
              profile={profile}
 | 
			
		||||
              moderationOpts={moderationOpts}
 | 
			
		||||
            />
 | 
			
		||||
          </Toggle.Item>
 | 
			
		||||
        ))}
 | 
			
		||||
      </View>
 | 
			
		||||
    </Toggle.Group>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function StepSuggestedAccounts() {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const {track} = useAnalytics()
 | 
			
		||||
  const {state, dispatch} = React.useContext(Context)
 | 
			
		||||
  const {gtMobile} = useBreakpoints()
 | 
			
		||||
  const suggestedDids = React.useMemo(() => {
 | 
			
		||||
    return aggregateInterestItems(
 | 
			
		||||
      state.interestsStepResults.selectedInterests,
 | 
			
		||||
      state.interestsStepResults.apiResponse.suggestedAccountDids,
 | 
			
		||||
      state.interestsStepResults.apiResponse.suggestedAccountDids.default,
 | 
			
		||||
    )
 | 
			
		||||
  }, [state.interestsStepResults])
 | 
			
		||||
  const moderationOpts = useModerationOpts()
 | 
			
		||||
  const {
 | 
			
		||||
    isLoading: isProfilesLoading,
 | 
			
		||||
    isError,
 | 
			
		||||
    data,
 | 
			
		||||
    error,
 | 
			
		||||
  } = useProfilesQuery({
 | 
			
		||||
    handles: suggestedDids,
 | 
			
		||||
  })
 | 
			
		||||
  const [dids, setDids] = React.useState<string[]>([])
 | 
			
		||||
  const [saving, setSaving] = React.useState(false)
 | 
			
		||||
 | 
			
		||||
  const interestsText = React.useMemo(() => {
 | 
			
		||||
    const i = state.interestsStepResults.selectedInterests.map(
 | 
			
		||||
      i => INTEREST_TO_DISPLAY_NAME[i],
 | 
			
		||||
    )
 | 
			
		||||
    return i.join(', ')
 | 
			
		||||
  }, [state.interestsStepResults.selectedInterests])
 | 
			
		||||
 | 
			
		||||
  const handleContinue = React.useCallback(async () => {
 | 
			
		||||
    setSaving(true)
 | 
			
		||||
 | 
			
		||||
    if (dids.length) {
 | 
			
		||||
      dispatch({type: 'setSuggestedAccountsStepResults', accountDids: dids})
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setSaving(false)
 | 
			
		||||
    dispatch({type: 'next'})
 | 
			
		||||
    track('OnboardingV2:StepSuggestedAccounts:Start', {
 | 
			
		||||
      selectedAccountsLength: dids.length,
 | 
			
		||||
    })
 | 
			
		||||
  }, [dids, setSaving, dispatch, track])
 | 
			
		||||
 | 
			
		||||
  const handleSkip = React.useCallback(() => {
 | 
			
		||||
    // if a user comes back and clicks skip, erase follows
 | 
			
		||||
    dispatch({type: 'setSuggestedAccountsStepResults', accountDids: []})
 | 
			
		||||
    dispatch({type: 'next'})
 | 
			
		||||
  }, [dispatch])
 | 
			
		||||
 | 
			
		||||
  const isLoading = isProfilesLoading && moderationOpts
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    track('OnboardingV2:StepSuggestedAccounts:Start')
 | 
			
		||||
  }, [track])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View style={[a.align_start]}>
 | 
			
		||||
      <IconCircle icon={At} style={[a.mb_2xl]} />
 | 
			
		||||
 | 
			
		||||
      <Title>
 | 
			
		||||
        <Trans>Here are some accounts for your to follow</Trans>
 | 
			
		||||
      </Title>
 | 
			
		||||
      <Description>
 | 
			
		||||
        {state.interestsStepResults.selectedInterests.length ? (
 | 
			
		||||
          <Trans>Based on your interest in {interestsText}</Trans>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Trans>These are popular accounts you might like.</Trans>
 | 
			
		||||
        )}
 | 
			
		||||
      </Description>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.w_full, a.pt_xl]}>
 | 
			
		||||
        {isLoading ? (
 | 
			
		||||
          <View style={[a.gap_md]}>
 | 
			
		||||
            {Array(10)
 | 
			
		||||
              .fill(0)
 | 
			
		||||
              .map((_, i) => (
 | 
			
		||||
                <SuggestedAccountCardPlaceholder key={i} />
 | 
			
		||||
              ))}
 | 
			
		||||
          </View>
 | 
			
		||||
        ) : isError || !data ? (
 | 
			
		||||
          <Text>{error?.toString()}</Text>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Inner
 | 
			
		||||
            profiles={data.profiles}
 | 
			
		||||
            onSelect={setDids}
 | 
			
		||||
            moderationOpts={moderationOpts}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <OnboardingControls.Portal>
 | 
			
		||||
        <View
 | 
			
		||||
          style={[
 | 
			
		||||
            a.gap_md,
 | 
			
		||||
            gtMobile ? {flexDirection: 'row-reverse'} : a.flex_col,
 | 
			
		||||
          ]}>
 | 
			
		||||
          <Button
 | 
			
		||||
            disabled={dids.length === 0}
 | 
			
		||||
            variant="gradient"
 | 
			
		||||
            color="gradient_sky"
 | 
			
		||||
            size="large"
 | 
			
		||||
            label={_(
 | 
			
		||||
              msg`Follow selected accounts and continue to then next step`,
 | 
			
		||||
            )}
 | 
			
		||||
            onPress={handleContinue}>
 | 
			
		||||
            <ButtonText>
 | 
			
		||||
              <Trans>Follow All</Trans>
 | 
			
		||||
            </ButtonText>
 | 
			
		||||
            <ButtonIcon icon={saving ? Loader : Plus} position="right" />
 | 
			
		||||
          </Button>
 | 
			
		||||
          <Button
 | 
			
		||||
            variant="solid"
 | 
			
		||||
            color="secondary"
 | 
			
		||||
            size="large"
 | 
			
		||||
            label={_(
 | 
			
		||||
              msg`Continue to the next step without following any accounts`,
 | 
			
		||||
            )}
 | 
			
		||||
            onPress={handleSkip}>
 | 
			
		||||
            <ButtonText>
 | 
			
		||||
              <Trans>Skip</Trans>
 | 
			
		||||
            </ButtonText>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </View>
 | 
			
		||||
      </OnboardingControls.Portal>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										113
									
								
								src/screens/Onboarding/StepTopicalFeeds.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,113 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {useLingui} from '@lingui/react'
 | 
			
		||||
import {msg, Trans} from '@lingui/macro'
 | 
			
		||||
 | 
			
		||||
import {atoms as a} from '#/alf'
 | 
			
		||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
 | 
			
		||||
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
 | 
			
		||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 | 
			
		||||
import * as Toggle from '#/components/forms/Toggle'
 | 
			
		||||
import {Loader} from '#/components/Loader'
 | 
			
		||||
import {useAnalytics} from '#/lib/analytics/analytics'
 | 
			
		||||
 | 
			
		||||
import {Context} from '#/screens/Onboarding/state'
 | 
			
		||||
import {
 | 
			
		||||
  Title,
 | 
			
		||||
  Description,
 | 
			
		||||
  OnboardingControls,
 | 
			
		||||
} from '#/screens/Onboarding/Layout'
 | 
			
		||||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
 | 
			
		||||
import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data'
 | 
			
		||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
 | 
			
		||||
import {IconCircle} from '#/screens/Onboarding/IconCircle'
 | 
			
		||||
 | 
			
		||||
export function StepTopicalFeeds() {
 | 
			
		||||
  const {_} = useLingui()
 | 
			
		||||
  const {track} = useAnalytics()
 | 
			
		||||
  const {state, dispatch} = React.useContext(Context)
 | 
			
		||||
  const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
 | 
			
		||||
  const [saving, setSaving] = React.useState(false)
 | 
			
		||||
  const suggestedFeedUris = React.useMemo(() => {
 | 
			
		||||
    return aggregateInterestItems(
 | 
			
		||||
      state.interestsStepResults.selectedInterests,
 | 
			
		||||
      state.interestsStepResults.apiResponse.suggestedFeedUris,
 | 
			
		||||
      state.interestsStepResults.apiResponse.suggestedFeedUris.default,
 | 
			
		||||
    ).slice(0, 10)
 | 
			
		||||
  }, [state.interestsStepResults])
 | 
			
		||||
 | 
			
		||||
  const interestsText = React.useMemo(() => {
 | 
			
		||||
    const i = state.interestsStepResults.selectedInterests.map(
 | 
			
		||||
      i => INTEREST_TO_DISPLAY_NAME[i],
 | 
			
		||||
    )
 | 
			
		||||
    return i.join(', ')
 | 
			
		||||
  }, [state.interestsStepResults.selectedInterests])
 | 
			
		||||
 | 
			
		||||
  const saveFeeds = React.useCallback(async () => {
 | 
			
		||||
    setSaving(true)
 | 
			
		||||
 | 
			
		||||
    dispatch({type: 'setTopicalFeedsStepResults', feedUris: selectedFeedUris})
 | 
			
		||||
 | 
			
		||||
    setSaving(false)
 | 
			
		||||
    dispatch({type: 'next'})
 | 
			
		||||
    track('OnboardingV2:StepTopicalFeeds:End', {
 | 
			
		||||
      selectedFeeds: selectedFeedUris,
 | 
			
		||||
      selectedFeedsLength: selectedFeedUris.length,
 | 
			
		||||
    })
 | 
			
		||||
  }, [selectedFeedUris, dispatch, track])
 | 
			
		||||
 | 
			
		||||
  React.useEffect(() => {
 | 
			
		||||
    track('OnboardingV2:StepTopicalFeeds:Start')
 | 
			
		||||
  }, [track])
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <View style={[a.align_start]}>
 | 
			
		||||
      <IconCircle icon={ListMagnifyingGlass} style={[a.mb_2xl]} />
 | 
			
		||||
 | 
			
		||||
      <Title>
 | 
			
		||||
        <Trans>Feeds can be topical as well!</Trans>
 | 
			
		||||
      </Title>
 | 
			
		||||
      <Description>
 | 
			
		||||
        {state.interestsStepResults.selectedInterests.length ? (
 | 
			
		||||
          <Trans>
 | 
			
		||||
            Here are some topical feeds based on your interests: {interestsText}
 | 
			
		||||
            . You can choose to follow as many as you like.
 | 
			
		||||
          </Trans>
 | 
			
		||||
        ) : (
 | 
			
		||||
          <Trans>
 | 
			
		||||
            Here are some popular topical feeds. You can choose to follow as
 | 
			
		||||
            many as you like.
 | 
			
		||||
          </Trans>
 | 
			
		||||
        )}
 | 
			
		||||
      </Description>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.w_full, a.pb_2xl, a.pt_2xl]}>
 | 
			
		||||
        <Toggle.Group
 | 
			
		||||
          values={selectedFeedUris}
 | 
			
		||||
          onChange={setSelectedFeedUris}
 | 
			
		||||
          label={_(msg`Select topical feeds to follow from the list below`)}>
 | 
			
		||||
          <View style={[a.gap_md]}>
 | 
			
		||||
            {suggestedFeedUris.map(uri => (
 | 
			
		||||
              <FeedCard key={uri} config={{default: false, uri}} />
 | 
			
		||||
            ))}
 | 
			
		||||
          </View>
 | 
			
		||||
        </Toggle.Group>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <OnboardingControls.Portal>
 | 
			
		||||
        <Button
 | 
			
		||||
          key={state.activeStep} // remove focus state on nav
 | 
			
		||||
          variant="gradient"
 | 
			
		||||
          color="gradient_sky"
 | 
			
		||||
          size="large"
 | 
			
		||||
          label={_(msg`Continue to next step`)}
 | 
			
		||||
          onPress={saveFeeds}>
 | 
			
		||||
          <ButtonText>
 | 
			
		||||
            <Trans>Continue</Trans>
 | 
			
		||||
          </ButtonText>
 | 
			
		||||
          <ButtonIcon icon={saving ? Loader : ChevronRight} position="right" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </OnboardingControls.Portal>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										38
									
								
								src/screens/Onboarding/index.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
 | 
			
		||||
import {Portal} from '#/components/Portal'
 | 
			
		||||
 | 
			
		||||
import {Context, initialState, reducer} from '#/screens/Onboarding/state'
 | 
			
		||||
import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout'
 | 
			
		||||
import {StepInterests} from '#/screens/Onboarding/StepInterests'
 | 
			
		||||
import {StepSuggestedAccounts} from '#/screens/Onboarding/StepSuggestedAccounts'
 | 
			
		||||
import {StepFollowingFeed} from '#/screens/Onboarding/StepFollowingFeed'
 | 
			
		||||
import {StepAlgoFeeds} from '#/screens/Onboarding/StepAlgoFeeds'
 | 
			
		||||
import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds'
 | 
			
		||||
import {StepFinished} from '#/screens/Onboarding/StepFinished'
 | 
			
		||||
import {StepModeration} from '#/screens/Onboarding/StepModeration'
 | 
			
		||||
 | 
			
		||||
export function Onboarding() {
 | 
			
		||||
  const [state, dispatch] = React.useReducer(reducer, {...initialState})
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Portal>
 | 
			
		||||
      <OnboardingControls.Provider>
 | 
			
		||||
        <Context.Provider
 | 
			
		||||
          value={React.useMemo(() => ({state, dispatch}), [state, dispatch])}>
 | 
			
		||||
          <Layout>
 | 
			
		||||
            {state.activeStep === 'interests' && <StepInterests />}
 | 
			
		||||
            {state.activeStep === 'suggestedAccounts' && (
 | 
			
		||||
              <StepSuggestedAccounts />
 | 
			
		||||
            )}
 | 
			
		||||
            {state.activeStep === 'followingFeed' && <StepFollowingFeed />}
 | 
			
		||||
            {state.activeStep === 'algoFeeds' && <StepAlgoFeeds />}
 | 
			
		||||
            {state.activeStep === 'topicalFeeds' && <StepTopicalFeeds />}
 | 
			
		||||
            {state.activeStep === 'moderation' && <StepModeration />}
 | 
			
		||||
            {state.activeStep === 'finished' && <StepFinished />}
 | 
			
		||||
          </Layout>
 | 
			
		||||
        </Context.Provider>
 | 
			
		||||
      </OnboardingControls.Provider>
 | 
			
		||||
    </Portal>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										201
									
								
								src/screens/Onboarding/state.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,201 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
 | 
			
		||||
import {ApiResponseMap} from '#/screens/Onboarding/StepInterests/data'
 | 
			
		||||
import {logger} from '#/logger'
 | 
			
		||||
 | 
			
		||||
export type OnboardingState = {
 | 
			
		||||
  hasPrev: boolean
 | 
			
		||||
  totalSteps: number
 | 
			
		||||
  activeStep:
 | 
			
		||||
    | 'interests'
 | 
			
		||||
    | 'suggestedAccounts'
 | 
			
		||||
    | 'followingFeed'
 | 
			
		||||
    | 'algoFeeds'
 | 
			
		||||
    | 'topicalFeeds'
 | 
			
		||||
    | 'moderation'
 | 
			
		||||
    | 'finished'
 | 
			
		||||
  activeStepIndex: number
 | 
			
		||||
 | 
			
		||||
  interestsStepResults: {
 | 
			
		||||
    selectedInterests: string[]
 | 
			
		||||
    apiResponse: ApiResponseMap
 | 
			
		||||
  }
 | 
			
		||||
  suggestedAccountsStepResults: {
 | 
			
		||||
    accountDids: string[]
 | 
			
		||||
  }
 | 
			
		||||
  algoFeedsStepResults: {
 | 
			
		||||
    feedUris: string[]
 | 
			
		||||
  }
 | 
			
		||||
  topicalFeedsStepResults: {
 | 
			
		||||
    feedUris: string[]
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type OnboardingAction =
 | 
			
		||||
  | {
 | 
			
		||||
      type: 'next'
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      type: 'prev'
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      type: 'finish'
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      type: 'setInterestsStepResults'
 | 
			
		||||
      selectedInterests: string[]
 | 
			
		||||
      apiResponse: ApiResponseMap
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      type: 'setSuggestedAccountsStepResults'
 | 
			
		||||
      accountDids: string[]
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      type: 'setAlgoFeedsStepResults'
 | 
			
		||||
      feedUris: string[]
 | 
			
		||||
    }
 | 
			
		||||
  | {
 | 
			
		||||
      type: 'setTopicalFeedsStepResults'
 | 
			
		||||
      feedUris: string[]
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
export const initialState: OnboardingState = {
 | 
			
		||||
  hasPrev: false,
 | 
			
		||||
  totalSteps: 7,
 | 
			
		||||
  activeStep: 'interests',
 | 
			
		||||
  activeStepIndex: 1,
 | 
			
		||||
 | 
			
		||||
  interestsStepResults: {
 | 
			
		||||
    selectedInterests: [],
 | 
			
		||||
    apiResponse: {
 | 
			
		||||
      interests: [],
 | 
			
		||||
      suggestedAccountDids: {},
 | 
			
		||||
      suggestedFeedUris: {},
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  suggestedAccountsStepResults: {
 | 
			
		||||
    accountDids: [],
 | 
			
		||||
  },
 | 
			
		||||
  algoFeedsStepResults: {
 | 
			
		||||
    feedUris: [],
 | 
			
		||||
  },
 | 
			
		||||
  topicalFeedsStepResults: {
 | 
			
		||||
    feedUris: [],
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const Context = React.createContext<{
 | 
			
		||||
  state: OnboardingState
 | 
			
		||||
  dispatch: React.Dispatch<OnboardingAction>
 | 
			
		||||
}>({
 | 
			
		||||
  state: {...initialState},
 | 
			
		||||
  dispatch: () => {},
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export function reducer(
 | 
			
		||||
  s: OnboardingState,
 | 
			
		||||
  a: OnboardingAction,
 | 
			
		||||
): OnboardingState {
 | 
			
		||||
  let next = {...s}
 | 
			
		||||
 | 
			
		||||
  switch (a.type) {
 | 
			
		||||
    case 'next': {
 | 
			
		||||
      if (s.activeStep === 'interests') {
 | 
			
		||||
        next.activeStep = 'suggestedAccounts'
 | 
			
		||||
        next.activeStepIndex = 2
 | 
			
		||||
      } else if (s.activeStep === 'suggestedAccounts') {
 | 
			
		||||
        next.activeStep = 'followingFeed'
 | 
			
		||||
        next.activeStepIndex = 3
 | 
			
		||||
      } else if (s.activeStep === 'followingFeed') {
 | 
			
		||||
        next.activeStep = 'algoFeeds'
 | 
			
		||||
        next.activeStepIndex = 4
 | 
			
		||||
      } else if (s.activeStep === 'algoFeeds') {
 | 
			
		||||
        next.activeStep = 'topicalFeeds'
 | 
			
		||||
        next.activeStepIndex = 5
 | 
			
		||||
      } else if (s.activeStep === 'topicalFeeds') {
 | 
			
		||||
        next.activeStep = 'moderation'
 | 
			
		||||
        next.activeStepIndex = 6
 | 
			
		||||
      } else if (s.activeStep === 'moderation') {
 | 
			
		||||
        next.activeStep = 'finished'
 | 
			
		||||
        next.activeStepIndex = 7
 | 
			
		||||
      }
 | 
			
		||||
      break
 | 
			
		||||
    }
 | 
			
		||||
    case 'prev': {
 | 
			
		||||
      if (s.activeStep === 'suggestedAccounts') {
 | 
			
		||||
        next.activeStep = 'interests'
 | 
			
		||||
        next.activeStepIndex = 1
 | 
			
		||||
      } else if (s.activeStep === 'followingFeed') {
 | 
			
		||||
        next.activeStep = 'suggestedAccounts'
 | 
			
		||||
        next.activeStepIndex = 2
 | 
			
		||||
      } else if (s.activeStep === 'algoFeeds') {
 | 
			
		||||
        next.activeStep = 'followingFeed'
 | 
			
		||||
        next.activeStepIndex = 3
 | 
			
		||||
      } else if (s.activeStep === 'topicalFeeds') {
 | 
			
		||||
        next.activeStep = 'algoFeeds'
 | 
			
		||||
        next.activeStepIndex = 4
 | 
			
		||||
      } else if (s.activeStep === 'moderation') {
 | 
			
		||||
        next.activeStep = 'topicalFeeds'
 | 
			
		||||
        next.activeStepIndex = 5
 | 
			
		||||
      } else if (s.activeStep === 'finished') {
 | 
			
		||||
        next.activeStep = 'moderation'
 | 
			
		||||
        next.activeStepIndex = 6
 | 
			
		||||
      }
 | 
			
		||||
      break
 | 
			
		||||
    }
 | 
			
		||||
    case 'finish': {
 | 
			
		||||
      next = initialState
 | 
			
		||||
      break
 | 
			
		||||
    }
 | 
			
		||||
    case 'setInterestsStepResults': {
 | 
			
		||||
      next.interestsStepResults = {
 | 
			
		||||
        selectedInterests: a.selectedInterests,
 | 
			
		||||
        apiResponse: a.apiResponse,
 | 
			
		||||
      }
 | 
			
		||||
      break
 | 
			
		||||
    }
 | 
			
		||||
    case 'setSuggestedAccountsStepResults': {
 | 
			
		||||
      next.suggestedAccountsStepResults = {
 | 
			
		||||
        accountDids: next.suggestedAccountsStepResults.accountDids.concat(
 | 
			
		||||
          a.accountDids,
 | 
			
		||||
        ),
 | 
			
		||||
      }
 | 
			
		||||
      break
 | 
			
		||||
    }
 | 
			
		||||
    case 'setAlgoFeedsStepResults': {
 | 
			
		||||
      next.algoFeedsStepResults = {
 | 
			
		||||
        feedUris: a.feedUris,
 | 
			
		||||
      }
 | 
			
		||||
      break
 | 
			
		||||
    }
 | 
			
		||||
    case 'setTopicalFeedsStepResults': {
 | 
			
		||||
      next.topicalFeedsStepResults = {
 | 
			
		||||
        feedUris: next.topicalFeedsStepResults.feedUris.concat(a.feedUris),
 | 
			
		||||
      }
 | 
			
		||||
      break
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const state = {
 | 
			
		||||
    ...next,
 | 
			
		||||
    hasPrev: next.activeStep !== 'interests',
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  logger.debug(`onboarding`, {
 | 
			
		||||
    hasPrev: state.hasPrev,
 | 
			
		||||
    activeStep: state.activeStep,
 | 
			
		||||
    activeStepIndex: state.activeStepIndex,
 | 
			
		||||
    interestsStepResults: {
 | 
			
		||||
      selectedInterests: state.interestsStepResults.selectedInterests,
 | 
			
		||||
    },
 | 
			
		||||
    suggestedAccountsStepResults: state.suggestedAccountsStepResults,
 | 
			
		||||
    algoFeedsStepResults: state.algoFeedsStepResults,
 | 
			
		||||
    topicalFeedsStepResults: state.topicalFeedsStepResults,
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  if (s.activeStep !== state.activeStep) {
 | 
			
		||||
    logger.info(`onboarding: step changed`, {activeStep: state.activeStep})
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return state
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										112
									
								
								src/screens/Onboarding/util.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,112 @@
 | 
			
		|||
import {AppBskyGraphFollow, AppBskyGraphGetFollows} from '@atproto/api'
 | 
			
		||||
 | 
			
		||||
import {until} from '#/lib/async/until'
 | 
			
		||||
import {getAgent} from '#/state/session'
 | 
			
		||||
 | 
			
		||||
function shuffle(array: any) {
 | 
			
		||||
  let currentIndex = array.length,
 | 
			
		||||
    randomIndex
 | 
			
		||||
 | 
			
		||||
  // While there remain elements to shuffle.
 | 
			
		||||
  while (currentIndex > 0) {
 | 
			
		||||
    // Pick a remaining element.
 | 
			
		||||
    randomIndex = Math.floor(Math.random() * currentIndex)
 | 
			
		||||
    currentIndex--
 | 
			
		||||
 | 
			
		||||
    // And swap it with the current element.
 | 
			
		||||
    ;[array[currentIndex], array[randomIndex]] = [
 | 
			
		||||
      array[randomIndex],
 | 
			
		||||
      array[currentIndex],
 | 
			
		||||
    ]
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return array
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function aggregateInterestItems(
 | 
			
		||||
  interests: string[],
 | 
			
		||||
  map: {[key: string]: string[]},
 | 
			
		||||
  fallbackItems: string[],
 | 
			
		||||
) {
 | 
			
		||||
  const selected = interests.length
 | 
			
		||||
  const all = interests
 | 
			
		||||
    .map(i => {
 | 
			
		||||
      const suggestions = shuffle(map[i])
 | 
			
		||||
 | 
			
		||||
      if (selected === 1) {
 | 
			
		||||
        return suggestions // return all
 | 
			
		||||
      } else if (selected === 2) {
 | 
			
		||||
        return suggestions.slice(0, 5) // return 5
 | 
			
		||||
      } else {
 | 
			
		||||
        return suggestions.slice(0, 3) // return 3
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    .flat()
 | 
			
		||||
  // dedupe suggestions
 | 
			
		||||
  const results = Array.from(new Set(all))
 | 
			
		||||
 | 
			
		||||
  // backfill
 | 
			
		||||
  if (results.length < 20) {
 | 
			
		||||
    results.push(...shuffle(fallbackItems))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // dedupe and return 20
 | 
			
		||||
  return Array.from(new Set(results)).slice(0, 20)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function bulkWriteFollows(dids: string[]) {
 | 
			
		||||
  const session = getAgent().session
 | 
			
		||||
 | 
			
		||||
  if (!session) {
 | 
			
		||||
    throw new Error(`bulkWriteFollows failed: no session`)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const followRecords: AppBskyGraphFollow.Record[] = dids.map(did => {
 | 
			
		||||
    return {
 | 
			
		||||
      $type: 'app.bsky.graph.follow',
 | 
			
		||||
      subject: did,
 | 
			
		||||
      createdAt: new Date().toISOString(),
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
  const followWrites = followRecords.map(r => ({
 | 
			
		||||
    $type: 'com.atproto.repo.applyWrites#create',
 | 
			
		||||
    collection: 'app.bsky.graph.follow',
 | 
			
		||||
    value: r,
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  await getAgent().com.atproto.repo.applyWrites({
 | 
			
		||||
    repo: session.did,
 | 
			
		||||
    writes: followWrites,
 | 
			
		||||
  })
 | 
			
		||||
  await whenFollowsIndexed(session.did, res => !!res.data.follows.length)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function whenFollowsIndexed(
 | 
			
		||||
  actor: string,
 | 
			
		||||
  fn: (res: AppBskyGraphGetFollows.Response) => boolean,
 | 
			
		||||
) {
 | 
			
		||||
  await until(
 | 
			
		||||
    5, // 5 tries
 | 
			
		||||
    1e3, // 1s delay between tries
 | 
			
		||||
    fn,
 | 
			
		||||
    () =>
 | 
			
		||||
      getAgent().app.bsky.graph.getFollows({
 | 
			
		||||
        actor,
 | 
			
		||||
        limit: 1,
 | 
			
		||||
      }),
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Kinda hacky, but we want For Your or Discover to appear as the first pinned
 | 
			
		||||
 * feed after Following
 | 
			
		||||
 */
 | 
			
		||||
export function sortPrimaryAlgorithmFeeds(uris: string[]) {
 | 
			
		||||
  return uris.sort(uri => {
 | 
			
		||||
    return uri.includes('the-algorithm')
 | 
			
		||||
      ? -1
 | 
			
		||||
      : uri.includes('whats-hot')
 | 
			
		||||
      ? 0
 | 
			
		||||
      : 1
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -48,4 +48,5 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
 | 
			
		|||
  feedViewPrefs: DEFAULT_HOME_FEED_PREFS,
 | 
			
		||||
  threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
 | 
			
		||||
  userAge: 13, // TODO(pwi)
 | 
			
		||||
  interests: {tags: []},
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,14 +5,17 @@ import {
 | 
			
		|||
  BskyFeedViewPreference,
 | 
			
		||||
} from '@atproto/api'
 | 
			
		||||
 | 
			
		||||
export type ConfigurableLabelGroup =
 | 
			
		||||
  | 'nsfw'
 | 
			
		||||
  | 'nudity'
 | 
			
		||||
  | 'suggestive'
 | 
			
		||||
  | 'gore'
 | 
			
		||||
  | 'hate'
 | 
			
		||||
  | 'spam'
 | 
			
		||||
  | 'impersonation'
 | 
			
		||||
export const configurableLabelGroups = [
 | 
			
		||||
  'nsfw',
 | 
			
		||||
  'nudity',
 | 
			
		||||
  'suggestive',
 | 
			
		||||
  'gore',
 | 
			
		||||
  'hate',
 | 
			
		||||
  'spam',
 | 
			
		||||
  'impersonation',
 | 
			
		||||
] as const
 | 
			
		||||
export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number]
 | 
			
		||||
 | 
			
		||||
export type LabelGroup =
 | 
			
		||||
  | ConfigurableLabelGroup
 | 
			
		||||
  | 'illegal'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,6 +24,7 @@ import {STALE} from '#/state/queries'
 | 
			
		|||
import {track} from '#/lib/analytics/analytics'
 | 
			
		||||
 | 
			
		||||
export const RQKEY = (did: string) => ['profile', did]
 | 
			
		||||
export const profilesQueryKey = (handles: string[]) => ['profiles', handles]
 | 
			
		||||
 | 
			
		||||
export function useProfileQuery({did}: {did: string | undefined}) {
 | 
			
		||||
  const {currentAccount} = useSession()
 | 
			
		||||
| 
						 | 
				
			
			@ -45,6 +46,17 @@ export function useProfileQuery({did}: {did: string | undefined}) {
 | 
			
		|||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useProfilesQuery({handles}: {handles: string[]}) {
 | 
			
		||||
  return useQuery({
 | 
			
		||||
    staleTime: STALE.MINUTES.FIVE,
 | 
			
		||||
    queryKey: profilesQueryKey(handles),
 | 
			
		||||
    queryFn: async () => {
 | 
			
		||||
      const res = await getAgent().getProfiles({actors: handles})
 | 
			
		||||
      return res.data
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface ProfileUpdateParams {
 | 
			
		||||
  profile: AppBskyActorDefs.ProfileView
 | 
			
		||||
  updates:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,7 @@ import {
 | 
			
		|||
} from '#/components/Button'
 | 
			
		||||
import {H1} from '#/components/Typography'
 | 
			
		||||
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight'
 | 
			
		||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
 | 
			
		||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
 | 
			
		||||
 | 
			
		||||
export function Buttons() {
 | 
			
		||||
| 
						 | 
				
			
			@ -91,14 +92,16 @@ export function Buttons() {
 | 
			
		|||
            )}
 | 
			
		||||
          </View>
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.flex_wrap, a.gap_md, a.align_start]}>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="gradient"
 | 
			
		||||
          color="gradient_sky"
 | 
			
		||||
          size="large"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonText>Link out</ButtonText>
 | 
			
		||||
          <ButtonIcon icon={ArrowTopRight} />
 | 
			
		||||
          <ButtonIcon icon={ArrowTopRight} position="right" />
 | 
			
		||||
        </Button>
 | 
			
		||||
 | 
			
		||||
        <Button
 | 
			
		||||
| 
						 | 
				
			
			@ -107,7 +110,7 @@ export function Buttons() {
 | 
			
		|||
          size="small"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonText>Link out</ButtonText>
 | 
			
		||||
          <ButtonIcon icon={ArrowTopRight} />
 | 
			
		||||
          <ButtonIcon icon={ArrowTopRight} position="right" />
 | 
			
		||||
        </Button>
 | 
			
		||||
 | 
			
		||||
        <Button
 | 
			
		||||
| 
						 | 
				
			
			@ -115,8 +118,86 @@ export function Buttons() {
 | 
			
		|||
          color="gradient_sky"
 | 
			
		||||
          size="small"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonIcon icon={Globe} />
 | 
			
		||||
          <ButtonText>See the world</ButtonText>
 | 
			
		||||
          <ButtonText>Link xxxxxx</ButtonText>
 | 
			
		||||
        </Button>
 | 
			
		||||
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="gradient"
 | 
			
		||||
          color="gradient_sky"
 | 
			
		||||
          size="small"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonIcon icon={Globe} position="left" />
 | 
			
		||||
          <ButtonText>Link out</ButtonText>
 | 
			
		||||
        </Button>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.flex_row, a.gap_md, a.align_start]}>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="solid"
 | 
			
		||||
          color="primary"
 | 
			
		||||
          size="large"
 | 
			
		||||
          shape="round"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonIcon icon={ChevronLeft} />
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="gradient"
 | 
			
		||||
          color="gradient_sunset"
 | 
			
		||||
          size="small"
 | 
			
		||||
          shape="round"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonIcon icon={ChevronLeft} />
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="outline"
 | 
			
		||||
          color="primary"
 | 
			
		||||
          size="large"
 | 
			
		||||
          shape="round"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonIcon icon={ChevronLeft} />
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="ghost"
 | 
			
		||||
          color="primary"
 | 
			
		||||
          size="small"
 | 
			
		||||
          shape="round"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonIcon icon={ChevronLeft} />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </View>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.flex_row, a.gap_md, a.align_start]}>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="solid"
 | 
			
		||||
          color="primary"
 | 
			
		||||
          size="large"
 | 
			
		||||
          shape="square"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonIcon icon={ChevronLeft} />
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="gradient"
 | 
			
		||||
          color="gradient_sunset"
 | 
			
		||||
          size="small"
 | 
			
		||||
          shape="square"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonIcon icon={ChevronLeft} />
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="outline"
 | 
			
		||||
          color="primary"
 | 
			
		||||
          size="large"
 | 
			
		||||
          shape="square"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonIcon icon={ChevronLeft} />
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="ghost"
 | 
			
		||||
          color="primary"
 | 
			
		||||
          size="small"
 | 
			
		||||
          shape="square"
 | 
			
		||||
          label="Link out">
 | 
			
		||||
          <ButtonIcon icon={ChevronLeft} />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </View>
 | 
			
		||||
    </View>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -209,6 +209,23 @@ export function Forms() {
 | 
			
		|||
            Show
 | 
			
		||||
          </ToggleButton.Button>
 | 
			
		||||
        </ToggleButton.Group>
 | 
			
		||||
 | 
			
		||||
        <View>
 | 
			
		||||
          <ToggleButton.Group
 | 
			
		||||
            label="Preferences"
 | 
			
		||||
            values={toggleGroupDValues}
 | 
			
		||||
            onChange={setToggleGroupDValues}>
 | 
			
		||||
            <ToggleButton.Button name="hide" label="Hide">
 | 
			
		||||
              Hide
 | 
			
		||||
            </ToggleButton.Button>
 | 
			
		||||
            <ToggleButton.Button name="warn" label="Warn">
 | 
			
		||||
              Warn
 | 
			
		||||
            </ToggleButton.Button>
 | 
			
		||||
            <ToggleButton.Button name="show" label="Show">
 | 
			
		||||
              Show
 | 
			
		||||
            </ToggleButton.Button>
 | 
			
		||||
          </ToggleButton.Group>
 | 
			
		||||
        </View>
 | 
			
		||||
      </View>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,38 +1,39 @@
 | 
			
		|||
import React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
 | 
			
		||||
import {atoms as a} from '#/alf'
 | 
			
		||||
import {useTheme, atoms as a} from '#/alf'
 | 
			
		||||
import {ButtonText} from '#/components/Button'
 | 
			
		||||
import {Link} from '#/components/Link'
 | 
			
		||||
import {H1, H3} from '#/components/Typography'
 | 
			
		||||
import {InlineLink, Link} from '#/components/Link'
 | 
			
		||||
import {H1, H3, Text} from '#/components/Typography'
 | 
			
		||||
 | 
			
		||||
export function Links() {
 | 
			
		||||
  const t = useTheme()
 | 
			
		||||
  return (
 | 
			
		||||
    <View style={[a.gap_md, a.align_start]}>
 | 
			
		||||
      <H1>Links</H1>
 | 
			
		||||
 | 
			
		||||
      <View style={[a.gap_md, a.align_start]}>
 | 
			
		||||
        <Link
 | 
			
		||||
        <InlineLink
 | 
			
		||||
          to="https://blueskyweb.xyz"
 | 
			
		||||
          warnOnMismatchingTextChild
 | 
			
		||||
          style={[a.text_md]}>
 | 
			
		||||
          External
 | 
			
		||||
        </Link>
 | 
			
		||||
        <Link to="https://blueskyweb.xyz" style={[a.text_md]}>
 | 
			
		||||
        </InlineLink>
 | 
			
		||||
        <InlineLink to="https://blueskyweb.xyz" style={[a.text_md]}>
 | 
			
		||||
          <H3>External with custom children</H3>
 | 
			
		||||
        </Link>
 | 
			
		||||
        <Link
 | 
			
		||||
        </InlineLink>
 | 
			
		||||
        <InlineLink
 | 
			
		||||
          to="https://blueskyweb.xyz"
 | 
			
		||||
          warnOnMismatchingTextChild
 | 
			
		||||
          style={[a.text_lg]}>
 | 
			
		||||
          https://blueskyweb.xyz
 | 
			
		||||
        </Link>
 | 
			
		||||
        <Link
 | 
			
		||||
        </InlineLink>
 | 
			
		||||
        <InlineLink
 | 
			
		||||
          to="https://bsky.app/profile/bsky.app"
 | 
			
		||||
          warnOnMismatchingTextChild
 | 
			
		||||
          style={[a.text_md]}>
 | 
			
		||||
          Internal
 | 
			
		||||
        </Link>
 | 
			
		||||
        </InlineLink>
 | 
			
		||||
 | 
			
		||||
        <Link
 | 
			
		||||
          variant="solid"
 | 
			
		||||
| 
						 | 
				
			
			@ -42,6 +43,29 @@ export function Links() {
 | 
			
		|||
          to="https://bsky.app/profile/bsky.app">
 | 
			
		||||
          <ButtonText>Link as a button</ButtonText>
 | 
			
		||||
        </Link>
 | 
			
		||||
 | 
			
		||||
        <Link
 | 
			
		||||
          label="View @bsky.app's profile"
 | 
			
		||||
          to="https://bsky.app/profile/bsky.app">
 | 
			
		||||
          <View
 | 
			
		||||
            style={[
 | 
			
		||||
              a.flex_row,
 | 
			
		||||
              a.align_center,
 | 
			
		||||
              a.gap_md,
 | 
			
		||||
              a.rounded_md,
 | 
			
		||||
              a.p_md,
 | 
			
		||||
              t.atoms.bg_contrast_25,
 | 
			
		||||
            ]}>
 | 
			
		||||
            <View
 | 
			
		||||
              style={[
 | 
			
		||||
                {width: 32, height: 32},
 | 
			
		||||
                a.rounded_full,
 | 
			
		||||
                t.atoms.bg_contrast_200,
 | 
			
		||||
              ]}
 | 
			
		||||
            />
 | 
			
		||||
            <Text>View @bsky.app's profile</Text>
 | 
			
		||||
          </View>
 | 
			
		||||
        </Link>
 | 
			
		||||
      </View>
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,6 +3,7 @@ import {View} from 'react-native'
 | 
			
		|||
 | 
			
		||||
import {atoms as a} from '#/alf'
 | 
			
		||||
import {Text, H1, H2, H3, H4, H5, H6, P} from '#/components/Typography'
 | 
			
		||||
import {RichText} from '#/components/RichText'
 | 
			
		||||
 | 
			
		||||
export function Typography() {
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			@ -25,6 +26,16 @@ export function Typography() {
 | 
			
		|||
      <Text style={[a.text_sm]}>atoms.text_sm</Text>
 | 
			
		||||
      <Text style={[a.text_xs]}>atoms.text_xs</Text>
 | 
			
		||||
      <Text style={[a.text_2xs]}>atoms.text_2xs</Text>
 | 
			
		||||
 | 
			
		||||
      <RichText
 | 
			
		||||
        resolveFacets
 | 
			
		||||
        value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`}
 | 
			
		||||
      />
 | 
			
		||||
      <RichText
 | 
			
		||||
        resolveFacets
 | 
			
		||||
        value={`This is rich text. It can have mentions like @bsky.app or links like https://blueskyweb.xyz`}
 | 
			
		||||
        style={[a.text_xl]}
 | 
			
		||||
      />
 | 
			
		||||
    </View>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
import * as React from 'react'
 | 
			
		||||
import {View} from 'react-native'
 | 
			
		||||
import {PWI_ENABLED} from '#/lib/build-flags'
 | 
			
		||||
import {PWI_ENABLED, NEW_ONBOARDING_ENABLED} from '#/lib/build-flags'
 | 
			
		||||
 | 
			
		||||
// Based on @react-navigation/native-stack/src/createNativeStackNavigator.ts
 | 
			
		||||
// MIT License
 | 
			
		||||
| 
						 | 
				
			
			@ -38,6 +38,7 @@ import {isWeb} from 'platform/detection'
 | 
			
		|||
import {Deactivated} from '#/screens/Deactivated'
 | 
			
		||||
import {LoggedOut} from '../com/auth/LoggedOut'
 | 
			
		||||
import {Onboarding} from '../com/auth/Onboarding'
 | 
			
		||||
import {Onboarding as NewOnboarding} from '#/screens/Onboarding'
 | 
			
		||||
 | 
			
		||||
type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & {
 | 
			
		||||
  requireAuth?: boolean
 | 
			
		||||
| 
						 | 
				
			
			@ -111,7 +112,11 @@ function NativeStackNavigator({
 | 
			
		|||
    return <LoggedOut onDismiss={() => setShowLoggedOut(false)} />
 | 
			
		||||
  }
 | 
			
		||||
  if (onboardingState.isActive) {
 | 
			
		||||
    return <Onboarding />
 | 
			
		||||
    if (NEW_ONBOARDING_ENABLED) {
 | 
			
		||||
      return <NewOnboarding />
 | 
			
		||||
    } else {
 | 
			
		||||
      return <Onboarding />
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  const newDescriptors: typeof descriptors = {}
 | 
			
		||||
  for (let key in descriptors) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -40,26 +40,7 @@
 | 
			
		|||
        /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */
 | 
			
		||||
        -webkit-text-size-adjust: 100%;
 | 
			
		||||
        height: calc(100% + env(safe-area-inset-top));
 | 
			
		||||
        scrollbar-gutter: stable;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      /* Remove autofill styles on Webkit */
 | 
			
		||||
      input:-webkit-autofill,
 | 
			
		||||
      input:-webkit-autofill:hover, 
 | 
			
		||||
      input:-webkit-autofill:focus,
 | 
			
		||||
      textarea:-webkit-autofill,
 | 
			
		||||
      textarea:-webkit-autofill:hover,
 | 
			
		||||
      textarea:-webkit-autofill:focus,
 | 
			
		||||
      select:-webkit-autofill,
 | 
			
		||||
      select:-webkit-autofill:hover,
 | 
			
		||||
      select:-webkit-autofill:focus {
 | 
			
		||||
        border: 0;
 | 
			
		||||
        -webkit-text-fill-color: transparent;
 | 
			
		||||
        -webkit-box-shadow: none;
 | 
			
		||||
      }
 | 
			
		||||
      /* Force left-align date/time inputs on iOS mobile */
 | 
			
		||||
      input::-webkit-date-and-time-value {
 | 
			
		||||
        text-align: left;
 | 
			
		||||
        scrollbar-gutter: stable both-edges;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      /* Color theming */
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +52,7 @@
 | 
			
		|||
      html.colorMode--dark {
 | 
			
		||||
        --text: white;
 | 
			
		||||
        --background: hsl(211, 20%, 4%);
 | 
			
		||||
        --backgroundLight: hsl(211, 20%, 10%);
 | 
			
		||||
        --backgroundLight: hsl(211, 20%, 20%);
 | 
			
		||||
        color-scheme: dark;
 | 
			
		||||
      }
 | 
			
		||||
      @media (prefers-color-scheme: light) {
 | 
			
		||||
| 
						 | 
				
			
			@ -85,11 +66,33 @@
 | 
			
		|||
        html.colorMode--system {
 | 
			
		||||
          --text: white;
 | 
			
		||||
          --background: hsl(211, 20%, 4%);
 | 
			
		||||
          --backgroundLight: hsl(211, 20%, 10%);
 | 
			
		||||
          --backgroundLight: hsl(211, 20%, 20%);
 | 
			
		||||
          color-scheme: dark;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      ::selection {
 | 
			
		||||
        background-color: var(--backgroundLight);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      /* Remove autofill styles on Webkit */
 | 
			
		||||
      input:autofill,
 | 
			
		||||
      input:-webkit-autofill,
 | 
			
		||||
      input:-webkit-autofill:hover,
 | 
			
		||||
      input:-webkit-autofill:focus,
 | 
			
		||||
      input:-webkit-autofill:active{
 | 
			
		||||
          -webkit-background-clip: text;
 | 
			
		||||
          -webkit-text-fill-color: var(--text);
 | 
			
		||||
          transition: background-color 5000s ease-in-out 0s;
 | 
			
		||||
          box-shadow: inset 0 0 20px 20px var(--background);
 | 
			
		||||
          background: var(--background);
 | 
			
		||||
          color: var(--text);
 | 
			
		||||
      }
 | 
			
		||||
      /* Force left-align date/time inputs on iOS mobile */
 | 
			
		||||
      input::-webkit-date-and-time-value {
 | 
			
		||||
        text-align: left;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      body {
 | 
			
		||||
        display: flex;
 | 
			
		||||
        /* Allows you to scroll below the viewport; default value is visible */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||