Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

[Clipclops] New routes with placeholder screens (#3725)

* add new routes with placeholder screens

* gate content

* add filled envelope style

* swap filled state

* switch to `useAgent`

authored by samuel.fm and committed by

GitHub ce85375c 1af59ca8

+486 -19
+1
assets/icons/envelope_filled_stroke2_corner0_rounded.svg
···
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
+1
assets/icons/settingsSliderVertical_stroke2_corner0_rounded.svg
···
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 3a1 1 0 0 1 1 1v1.126a4 4 0 0 1 0 7.748V20a1 1 0 1 1-2 0v-7.126a4 4 0 0 1 0-7.748V4a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v9.126a4 4 0 1 1-2 0V4a1 1 0 0 1 1-1ZM7 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4Zm10 8a2 2 0 1 0 0 4 2 2 0 1 0 0-4Z" clip-rule="evenodd"/></svg>
+2
bskyweb/cmd/bskyweb/server.go
··· 200 e.GET("/support/community-guidelines", server.WebGeneric) 201 e.GET("/support/copyright", server.WebGeneric) 202 e.GET("/intent/compose", server.WebGeneric) 203 204 // profile endpoints; only first populates info 205 e.GET("/profile/:handleOrDID", server.WebProfile)
··· 200 e.GET("/support/community-guidelines", server.WebGeneric) 201 e.GET("/support/copyright", server.WebGeneric) 202 e.GET("/intent/compose", server.WebGeneric) 203 + e.GET("/messages", server.WebGeneric) 204 + e.GET("/messages/:conversation", server.WebGeneric) 205 206 // profile endpoints; only first populates info 207 e.GET("/profile/:handleOrDID", server.WebProfile)
+50
src/Navigation.tsx
··· 25 FeedsTabNavigatorParams, 26 FlatNavigatorParams, 27 HomeTabNavigatorParams, 28 MyProfileTabNavigatorParams, 29 NotificationsTabNavigatorParams, 30 SearchTabNavigatorParams, ··· 46 import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' 47 import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' 48 import {router} from './routes' 49 import {useModalControls} from './state/modals' 50 import {useUnreadNotifications} from './state/queries/notifications/unread' 51 import {useSession} from './state/session' ··· 92 createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>() 93 const MyProfileTab = 94 createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>() 95 const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>() 96 const Tab = createBottomTabNavigator<BottomTabNavigatorParams>() 97 ··· 290 getComponent={() => HashtagScreen} 291 options={{title: title(msg`Hashtag`)}} 292 /> 293 </> 294 ) 295 } ··· 322 <Tab.Screen 323 name="MyProfileTab" 324 getComponent={() => MyProfileTabNavigator} 325 /> 326 </Tab.Navigator> 327 ) ··· 429 ) 430 } 431 432 /** 433 * The FlatNavigator is used by Web to represent the routes 434 * in a single ("flat") stack. ··· 468 name="Notifications" 469 getComponent={() => NotificationsScreen} 470 options={{title: title(msg`Notifications`), requireAuth: true}} 471 /> 472 {commonScreens(Flat as typeof HomeTab, numUnread)} 473 </Flat.Navigator> ··· 521 } 522 if (name === 'Home') { 523 return buildStateObject('HomeTab', 'Home', params) 524 } 525 // if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work 526 return buildStateObject('HomeTab', name, params, [
··· 25 FeedsTabNavigatorParams, 26 FlatNavigatorParams, 27 HomeTabNavigatorParams, 28 + MessagesTabNavigatorParams, 29 MyProfileTabNavigatorParams, 30 NotificationsTabNavigatorParams, 31 SearchTabNavigatorParams, ··· 47 import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' 48 import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' 49 import {router} from './routes' 50 + import {MessagesConversationScreen} from './screens/Messages/Conversation' 51 + import {MessagesListScreen} from './screens/Messages/List' 52 + import {MessagesSettingsScreen} from './screens/Messages/Settings' 53 import {useModalControls} from './state/modals' 54 import {useUnreadNotifications} from './state/queries/notifications/unread' 55 import {useSession} from './state/session' ··· 96 createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>() 97 const MyProfileTab = 98 createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>() 99 + const MessagesTab = 100 + createNativeStackNavigatorWithAuth<MessagesTabNavigatorParams>() 101 const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>() 102 const Tab = createBottomTabNavigator<BottomTabNavigatorParams>() 103 ··· 296 getComponent={() => HashtagScreen} 297 options={{title: title(msg`Hashtag`)}} 298 /> 299 + <Stack.Screen 300 + name="MessagesConversation" 301 + getComponent={() => MessagesConversationScreen} 302 + options={{title: title(msg`Chat`), requireAuth: true}} 303 + /> 304 + <Stack.Screen 305 + name="MessagesSettings" 306 + getComponent={() => MessagesSettingsScreen} 307 + options={{title: title(msg`Messaging settings`), requireAuth: true}} 308 + /> 309 </> 310 ) 311 } ··· 338 <Tab.Screen 339 name="MyProfileTab" 340 getComponent={() => MyProfileTabNavigator} 341 + /> 342 + <Tab.Screen 343 + name="MessagesTab" 344 + getComponent={() => MessagesTabNavigator} 345 /> 346 </Tab.Navigator> 347 ) ··· 449 ) 450 } 451 452 + function MessagesTabNavigator() { 453 + const pal = usePalette('default') 454 + return ( 455 + <MessagesTab.Navigator 456 + screenOptions={{ 457 + animation: isAndroid ? 'none' : undefined, 458 + gestureEnabled: true, 459 + fullScreenGestureEnabled: true, 460 + headerShown: false, 461 + animationDuration: 250, 462 + contentStyle: pal.view, 463 + }}> 464 + <MessagesTab.Screen 465 + name="MessagesList" 466 + getComponent={() => MessagesListScreen} 467 + options={{requireAuth: true}} 468 + /> 469 + {commonScreens(MessagesTab as typeof HomeTab)} 470 + </MessagesTab.Navigator> 471 + ) 472 + } 473 + 474 /** 475 * The FlatNavigator is used by Web to represent the routes 476 * in a single ("flat") stack. ··· 510 name="Notifications" 511 getComponent={() => NotificationsScreen} 512 options={{title: title(msg`Notifications`), requireAuth: true}} 513 + /> 514 + <Flat.Screen 515 + name="MessagesList" 516 + getComponent={() => MessagesListScreen} 517 + options={{title: title(msg`Messages`), requireAuth: true}} 518 /> 519 {commonScreens(Flat as typeof HomeTab, numUnread)} 520 </Flat.Navigator> ··· 568 } 569 if (name === 'Home') { 570 return buildStateObject('HomeTab', 'Home', params) 571 + } 572 + if (name === 'Messages') { 573 + return buildStateObject('MessagesTab', 'MessagesList', params) 574 } 575 // if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work 576 return buildStateObject('HomeTab', name, params, [
+4
src/components/icons/Envelope.tsx
··· 3 export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z', 5 })
··· 3 export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z', 5 }) 6 + 7 + export const Envelope_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M12 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z', 9 + })
+6
src/components/icons/SettingsSlider.tsx
···
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const SettingsSliderVertical_Stroke2_Corner0_Rounded = 4 + createSinglePathSVG({ 5 + path: 'M7 3a1 1 0 0 1 1 1v1.126a4 4 0 0 1 0 7.748V20a1 1 0 1 1-2 0v-7.126a4 4 0 0 1 0-7.748V4a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v9.126a4 4 0 1 1-2 0V4a1 1 0 0 1 1-1ZM7 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4Zm10 8a2 2 0 1 0 0 4 2 2 0 1 0 0-4Z', 6 + })
+1 -1
src/components/icons/TEMPLATE.tsx
··· 1 import React from 'react' 2 import Svg, {Path} from 'react-native-svg' 3 4 - import {useCommonSVGProps, Props} from '#/components/icons/common' 5 6 export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef( 7 function LogoImpl(props: Props, ref) {
··· 1 import React from 'react' 2 import Svg, {Path} from 'react-native-svg' 3 4 + import {Props, useCommonSVGProps} from '#/components/icons/common' 5 6 export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef( 7 function LogoImpl(props: Props, ref) {
+1
src/lib/analytics/types.ts
··· 76 'MobileShell:SearchButtonPressed': {} 77 'MobileShell:NotificationsButtonPressed': {} 78 'MobileShell:FeedsButtonPressed': {} 79 // NOTIFICATIONS events 80 'Notificatons:OpenApp': {} 81 // LISTS events
··· 76 'MobileShell:SearchButtonPressed': {} 77 'MobileShell:NotificationsButtonPressed': {} 78 'MobileShell:FeedsButtonPressed': {} 79 + 'MobileShell:MessagesButtonPressed': {} 80 // NOTIFICATIONS events 81 'Notificatons:OpenApp': {} 82 // LISTS events
+4 -1
src/lib/hooks/useNavigationTabState.ts
··· 1 import {useNavigationState} from '@react-navigation/native' 2 import {getTabState, TabState} from 'lib/routes/helpers' 3 4 export function useNavigationTabState() { ··· 10 isAtNotifications: 11 getTabState(state, 'Notifications') !== TabState.Outside, 12 isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside, 13 } 14 if ( 15 !res.isAtHome && 16 !res.isAtSearch && 17 !res.isAtFeeds && 18 !res.isAtNotifications && 19 - !res.isAtMyProfile 20 ) { 21 // HACK for some reason useNavigationState will give us pre-hydration results 22 // and not update after, so we force isAtHome if all came back false
··· 1 import {useNavigationState} from '@react-navigation/native' 2 + 3 import {getTabState, TabState} from 'lib/routes/helpers' 4 5 export function useNavigationTabState() { ··· 11 isAtNotifications: 12 getTabState(state, 'Notifications') !== TabState.Outside, 13 isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside, 14 + isAtMessages: getTabState(state, 'MessagesList') !== TabState.Outside, 15 } 16 if ( 17 !res.isAtHome && 18 !res.isAtSearch && 19 !res.isAtFeeds && 20 !res.isAtNotifications && 21 + !res.isAtMyProfile && 22 + !res.isAtMessages 23 ) { 24 // HACK for some reason useNavigationState will give us pre-hydration results 25 // and not update after, so we force isAtHome if all came back false
+1 -1
src/lib/routes/router.ts
··· 1 - import {RouteParams, Route} from './types' 2 3 export class Router { 4 routes: [string, Route][] = []
··· 1 + import {Route, RouteParams} from './types' 2 3 export class Router { 4 routes: [string, Route][] = []
+10
src/lib/routes/types.ts
··· 38 AccessibilitySettings: undefined 39 Search: {q?: string} 40 Hashtag: {tag: string; author?: string} 41 } 42 43 export type BottomTabNavigatorParams = CommonNavigatorParams & { ··· 46 FeedsTab: undefined 47 NotificationsTab: undefined 48 MyProfileTab: undefined 49 } 50 51 export type HomeTabNavigatorParams = CommonNavigatorParams & { ··· 68 MyProfile: undefined 69 } 70 71 export type FlatNavigatorParams = CommonNavigatorParams & { 72 Home: undefined 73 Search: {q?: string} 74 Feeds: undefined 75 Notifications: undefined 76 Hashtag: {tag: string; author?: string} 77 } 78 79 export type AllNavigatorParams = CommonNavigatorParams & { ··· 87 Notifications: undefined 88 MyProfileTab: undefined 89 Hashtag: {tag: string; author?: string} 90 } 91 92 // NOTE
··· 38 AccessibilitySettings: undefined 39 Search: {q?: string} 40 Hashtag: {tag: string; author?: string} 41 + MessagesConversation: {conversation: string} 42 + MessagesSettings: undefined 43 } 44 45 export type BottomTabNavigatorParams = CommonNavigatorParams & { ··· 48 FeedsTab: undefined 49 NotificationsTab: undefined 50 MyProfileTab: undefined 51 + MessagesTab: undefined 52 } 53 54 export type HomeTabNavigatorParams = CommonNavigatorParams & { ··· 71 MyProfile: undefined 72 } 73 74 + export type MessagesTabNavigatorParams = CommonNavigatorParams & { 75 + MessagesList: undefined 76 + } 77 + 78 export type FlatNavigatorParams = CommonNavigatorParams & { 79 Home: undefined 80 Search: {q?: string} 81 Feeds: undefined 82 Notifications: undefined 83 Hashtag: {tag: string; author?: string} 84 + MessagesList: undefined 85 } 86 87 export type AllNavigatorParams = CommonNavigatorParams & { ··· 95 Notifications: undefined 96 MyProfileTab: undefined 97 Hashtag: {tag: string; author?: string} 98 + MessagesTab: undefined 99 + MessagesList: undefined 100 } 101 102 // NOTE
+1
src/lib/statsig/gates.ts
··· 3 | 'autoexpand_suggestions_on_profile_follow_v2' 4 | 'disable_min_shell_on_foregrounding_v2' 5 | 'disable_poll_on_discover_v2' 6 | 'hide_vertical_scroll_indicators' 7 | 'show_follow_back_label_v2' 8 | 'start_session_with_following_v2'
··· 3 | 'autoexpand_suggestions_on_profile_follow_v2' 4 | 'disable_min_shell_on_foregrounding_v2' 5 | 'disable_poll_on_discover_v2' 6 + | 'dms' 7 | 'hide_vertical_scroll_indicators' 8 | 'show_follow_back_label_v2' 9 | 'start_session_with_following_v2'
+3
src/routes.ts
··· 37 CommunityGuidelines: '/support/community-guidelines', 38 CopyrightPolicy: '/support/copyright', 39 Hashtag: '/hashtag/:tag', 40 })
··· 37 CommunityGuidelines: '/support/community-guidelines', 38 CopyrightPolicy: '/support/copyright', 39 Hashtag: '/hashtag/:tag', 40 + MessagesList: '/messages', 41 + MessagesSettings: '/messages/settings', 42 + MessagesConversation: '/messages/:conversation', 43 })
+32
src/screens/Messages/Conversation/index.tsx
···
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 6 + 7 + import {CommonNavigatorParams} from '#/lib/routes/types' 8 + import {useGate} from '#/lib/statsig/statsig' 9 + import {ViewHeader} from '#/view/com/util/ViewHeader' 10 + import {ClipClopGate} from '../gate' 11 + 12 + type Props = NativeStackScreenProps< 13 + CommonNavigatorParams, 14 + 'MessagesConversation' 15 + > 16 + export function MessagesConversationScreen({route}: Props) { 17 + const chatId = route.params.conversation 18 + const {_} = useLingui() 19 + 20 + const gate = useGate() 21 + if (!gate('dms')) return <ClipClopGate /> 22 + 23 + return ( 24 + <View> 25 + <ViewHeader 26 + title={_(msg`Chat with ${chatId}`)} 27 + showOnDesktop 28 + showBorder 29 + /> 30 + </View> 31 + ) 32 + }
+234
src/screens/Messages/List/index.tsx
···
··· 1 + import React, {useCallback, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 6 + import {useInfiniteQuery} from '@tanstack/react-query' 7 + 8 + import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 9 + import {MessagesTabNavigatorParams} from '#/lib/routes/types' 10 + import {useGate} from '#/lib/statsig/statsig' 11 + import {cleanError} from '#/lib/strings/errors' 12 + import {logger} from '#/logger' 13 + import {useAgent} from '#/state/session' 14 + import {List} from '#/view/com/util/List' 15 + import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 16 + import {ViewHeader} from '#/view/com/util/ViewHeader' 17 + import {useTheme} from '#/alf' 18 + import {atoms as a} from '#/alf' 19 + import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' 20 + import {Link} from '#/components/Link' 21 + import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 22 + import {Text} from '#/components/Typography' 23 + import {ClipClopGate} from '../gate' 24 + 25 + type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'> 26 + export function MessagesListScreen({}: Props) { 27 + const {_} = useLingui() 28 + const t = useTheme() 29 + 30 + const renderButton = useCallback(() => { 31 + return ( 32 + <Link 33 + to="/messages/settings" 34 + accessibilityLabel={_(msg`Message settings`)} 35 + accessibilityHint={_(msg`Opens the message settings page`)}> 36 + <SettingsSlider size="lg" style={t.atoms.text} /> 37 + </Link> 38 + ) 39 + }, [_, t.atoms.text]) 40 + 41 + const initialNumToRender = useInitialNumToRender() 42 + const [isPTRing, setIsPTRing] = useState(false) 43 + 44 + const { 45 + data, 46 + isLoading, 47 + isFetchingNextPage, 48 + hasNextPage, 49 + fetchNextPage, 50 + error, 51 + refetch, 52 + } = usePlaceholderConversations() 53 + 54 + const isError = !!error 55 + 56 + const conversations = React.useMemo(() => { 57 + if (data?.pages) { 58 + return data.pages.flat() 59 + } 60 + return [] 61 + }, [data]) 62 + 63 + const onRefresh = React.useCallback(async () => { 64 + setIsPTRing(true) 65 + try { 66 + await refetch() 67 + } catch (err) { 68 + logger.error('Failed to refresh conversations', {message: err}) 69 + } 70 + setIsPTRing(false) 71 + }, [refetch, setIsPTRing]) 72 + 73 + const onEndReached = React.useCallback(async () => { 74 + if (isFetchingNextPage || !hasNextPage || isError) return 75 + try { 76 + await fetchNextPage() 77 + } catch (err) { 78 + logger.error('Failed to load more conversations', {message: err}) 79 + } 80 + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 81 + 82 + const gate = useGate() 83 + if (!gate('dms')) return <ClipClopGate /> 84 + 85 + if (conversations.length < 1) { 86 + return ( 87 + <ListMaybePlaceholder 88 + isLoading={isLoading} 89 + isError={isError} 90 + emptyType="results" 91 + emptyMessage={_( 92 + msg`You have no messages yet. Start a conversation with someone!`, 93 + )} 94 + errorMessage={cleanError(error)} 95 + onRetry={isError ? refetch : undefined} 96 + /> 97 + ) 98 + } 99 + 100 + return ( 101 + <View> 102 + <ViewHeader 103 + title={_(msg`Messages`)} 104 + showOnDesktop 105 + renderButton={renderButton} 106 + showBorder 107 + canGoBack={false} 108 + /> 109 + <List 110 + data={conversations} 111 + renderItem={({item}) => { 112 + return ( 113 + <Link 114 + to={`/messages/${item.profile.handle}`} 115 + style={[a.flex_1, a.pl_md, a.py_sm, a.gap_md, a.pr_2xl]}> 116 + <PreviewableUserAvatar 117 + did={item.profile.did} 118 + handle={item.profile.handle} 119 + size={44} 120 + avatar={item.profile.avatar} 121 + /> 122 + <View style={[a.flex_1]}> 123 + <View 124 + style={[ 125 + a.flex_row, 126 + a.align_center, 127 + a.justify_between, 128 + a.gap_lg, 129 + a.flex_1, 130 + ]}> 131 + <Text numberOfLines={1}> 132 + <Text style={item.unread && a.font_bold}> 133 + {item.profile.displayName || item.profile.handle} 134 + </Text>{' '} 135 + <Text style={t.atoms.text_contrast_medium}> 136 + @{item.profile.handle} 137 + </Text> 138 + </Text> 139 + {item.unread && ( 140 + <View 141 + style={[ 142 + a.ml_2xl, 143 + {backgroundColor: t.palette.primary_500}, 144 + a.rounded_full, 145 + {height: 7, width: 7}, 146 + ]} 147 + /> 148 + )} 149 + </View> 150 + <Text 151 + numberOfLines={2} 152 + style={[ 153 + a.text_sm, 154 + item.unread ? a.font_bold : t.atoms.text_contrast_medium, 155 + ]}> 156 + {item.lastMessage} 157 + </Text> 158 + </View> 159 + </Link> 160 + ) 161 + }} 162 + keyExtractor={item => item.profile.did} 163 + refreshing={isPTRing} 164 + onRefresh={onRefresh} 165 + onEndReached={onEndReached} 166 + ListFooterComponent={ 167 + <ListFooter 168 + isFetchingNextPage={isFetchingNextPage} 169 + error={cleanError(error)} 170 + onRetry={fetchNextPage} 171 + style={{borderColor: 'transparent'}} 172 + /> 173 + } 174 + onEndReachedThreshold={3} 175 + initialNumToRender={initialNumToRender} 176 + windowSize={11} 177 + /> 178 + </View> 179 + ) 180 + } 181 + 182 + function usePlaceholderConversations() { 183 + const {getAgent} = useAgent() 184 + 185 + return useInfiniteQuery({ 186 + queryKey: ['messages'], 187 + queryFn: async () => { 188 + const people = await getAgent().getProfiles({actors: PLACEHOLDER_PEOPLE}) 189 + return people.data.profiles.map(profile => ({ 190 + profile, 191 + unread: Math.random() > 0.5, 192 + lastMessage: getRandomPost(), 193 + })) 194 + }, 195 + initialPageParam: undefined, 196 + getNextPageParam: () => undefined, 197 + }) 198 + } 199 + 200 + const PLACEHOLDER_PEOPLE = [ 201 + 'pfrazee.com', 202 + 'haileyok.com', 203 + 'danabra.mov', 204 + 'esb.lol', 205 + 'samuel.bsky.team', 206 + ] 207 + 208 + function getRandomPost() { 209 + const num = Math.floor(Math.random() * 10) 210 + switch (num) { 211 + case 0: 212 + return 'hello' 213 + case 1: 214 + return 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua' 215 + case 2: 216 + return 'banger post' 217 + case 3: 218 + return 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua' 219 + case 4: 220 + return 'lol look at this bug' 221 + case 5: 222 + return 'wow' 223 + case 6: 224 + return "that's pretty cool, wow!" 225 + case 7: 226 + return 'I think this is a bug' 227 + case 8: 228 + return 'Hello World!' 229 + case 9: 230 + return 'DMs when???' 231 + default: 232 + return 'this is unlikely' 233 + } 234 + }
+24
src/screens/Messages/Settings/index.tsx
···
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 6 + 7 + import {CommonNavigatorParams} from '#/lib/routes/types' 8 + import {useGate} from '#/lib/statsig/statsig' 9 + import {ViewHeader} from '#/view/com/util/ViewHeader' 10 + import {ClipClopGate} from '../gate' 11 + 12 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesSettings'> 13 + export function MessagesSettingsScreen({}: Props) { 14 + const {_} = useLingui() 15 + 16 + const gate = useGate() 17 + if (!gate('dms')) return <ClipClopGate /> 18 + 19 + return ( 20 + <View> 21 + <ViewHeader title={_(msg`Settings`)} showOnDesktop /> 22 + </View> 23 + ) 24 + }
+17
src/screens/Messages/gate.tsx
···
··· 1 + import React from 'react' 2 + import {Text, View} from 'react-native' 3 + 4 + export function ClipClopGate() { 5 + return ( 6 + <View 7 + style={{ 8 + flex: 1, 9 + alignItems: 'center', 10 + justifyContent: 'center', 11 + gap: 20, 12 + }}> 13 + <Text style={{fontSize: 50}}>🐴</Text> 14 + <Text style={{textAlign: 'center'}}>Nice try</Text> 15 + </View> 16 + ) 17 + }
+45 -3
src/view/shell/bottom-bar/BottomBar.tsx
··· 24 } from '#/lib/icons' 25 import {clamp} from '#/lib/numbers' 26 import {getTabState, TabState} from '#/lib/routes/helpers' 27 import {s} from '#/lib/styles' 28 import {emitSoftReset} from '#/state/events' 29 import {useUnreadNotifications} from '#/state/queries/notifications/unread' ··· 39 import {Logotype} from '#/view/icons/Logotype' 40 import {useDialogControl} from '#/components/Dialog' 41 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 42 import {styles} from './BottomBarStyles' 43 44 - type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' 45 46 export function BottomBar({navigation}: BottomTabBarProps) { 47 const {hasSession, currentAccount} = useSession() ··· 50 const safeAreaInsets = useSafeAreaInsets() 51 const {track} = useAnalytics() 52 const {footerHeight} = useShellLayout() 53 - const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = 54 - useNavigationTabState() 55 const numUnreadNotifications = useUnreadNotifications() 56 const {footerMinimalShellTransform} = useMinimalShellMode() 57 const {data: profile} = useProfileQuery({did: currentAccount?.did}) ··· 60 const dedupe = useDedupe() 61 const accountSwitchControl = useDialogControl() 62 const playHaptic = useHaptics() 63 64 const showSignIn = React.useCallback(() => { 65 closeAllActiveElements() ··· 104 onPressTab('MyProfile') 105 }, [onPressTab]) 106 107 const onLongPressProfile = React.useCallback(() => { 108 playHaptic() 109 accountSwitchControl.open() ··· 220 : `${numUnreadNotifications} unread` 221 } 222 /> 223 <Btn 224 testID="bottomBarProfileBtn" 225 icon={
··· 24 } from '#/lib/icons' 25 import {clamp} from '#/lib/numbers' 26 import {getTabState, TabState} from '#/lib/routes/helpers' 27 + import {useGate} from '#/lib/statsig/statsig' 28 import {s} from '#/lib/styles' 29 import {emitSoftReset} from '#/state/events' 30 import {useUnreadNotifications} from '#/state/queries/notifications/unread' ··· 40 import {Logotype} from '#/view/icons/Logotype' 41 import {useDialogControl} from '#/components/Dialog' 42 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 43 + import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' 44 + import {Envelope_Filled_Stroke2_Corner0_Rounded as EnvelopeFilled} from '#/components/icons/Envelope' 45 import {styles} from './BottomBarStyles' 46 47 + type TabOptions = 48 + | 'Home' 49 + | 'Search' 50 + | 'Notifications' 51 + | 'MyProfile' 52 + | 'Feeds' 53 + | 'Messages' 54 55 export function BottomBar({navigation}: BottomTabBarProps) { 56 const {hasSession, currentAccount} = useSession() ··· 59 const safeAreaInsets = useSafeAreaInsets() 60 const {track} = useAnalytics() 61 const {footerHeight} = useShellLayout() 62 + const { 63 + isAtHome, 64 + isAtSearch, 65 + isAtFeeds, 66 + isAtNotifications, 67 + isAtMyProfile, 68 + isAtMessages, 69 + } = useNavigationTabState() 70 const numUnreadNotifications = useUnreadNotifications() 71 const {footerMinimalShellTransform} = useMinimalShellMode() 72 const {data: profile} = useProfileQuery({did: currentAccount?.did}) ··· 75 const dedupe = useDedupe() 76 const accountSwitchControl = useDialogControl() 77 const playHaptic = useHaptics() 78 + const gate = useGate() 79 80 const showSignIn = React.useCallback(() => { 81 closeAllActiveElements() ··· 120 onPressTab('MyProfile') 121 }, [onPressTab]) 122 123 + const onPressMessages = React.useCallback(() => { 124 + onPressTab('Messages') 125 + }, [onPressTab]) 126 + 127 const onLongPressProfile = React.useCallback(() => { 128 playHaptic() 129 accountSwitchControl.open() ··· 240 : `${numUnreadNotifications} unread` 241 } 242 /> 243 + {gate('dms') && ( 244 + <Btn 245 + testID="bottomBarMessagesBtn" 246 + icon={ 247 + isAtMessages ? ( 248 + <EnvelopeFilled 249 + size="lg" 250 + style={[styles.ctrlIcon, pal.text, styles.feedsIcon]} 251 + /> 252 + ) : ( 253 + <Envelope 254 + size="lg" 255 + style={[styles.ctrlIcon, pal.text, styles.feedsIcon]} 256 + /> 257 + ) 258 + } 259 + onPress={onPressMessages} 260 + accessibilityRole="tab" 261 + accessibilityLabel={_(msg`Messages`)} 262 + accessibilityHint="" 263 + /> 264 + )} 265 <Btn 266 testID="bottomBarProfileBtn" 267 icon={
+4
src/view/shell/bottom-bar/BottomBarStyles.tsx
··· 1 import {StyleSheet} from 'react-native' 2 import {colors} from 'lib/styles' 3 4 export const styles = StyleSheet.create({ ··· 64 }, 65 profileIcon: { 66 top: -4, 67 }, 68 onProfile: { 69 borderWidth: 1,
··· 1 import {StyleSheet} from 'react-native' 2 + 3 import {colors} from 'lib/styles' 4 5 export const styles = StyleSheet.create({ ··· 65 }, 66 profileIcon: { 67 top: -4, 68 + }, 69 + messagesIcon: { 70 + top: 2, 71 }, 72 onProfile: { 73 borderWidth: 1,
+31 -13
src/view/shell/bottom-bar/BottomBarWeb.tsx
··· 1 import React from 'react' 2 - import {usePalette} from 'lib/hooks/usePalette' 3 - import {useNavigationState} from '@react-navigation/native' 4 import Animated from 'react-native-reanimated' 5 import {useSafeAreaInsets} from 'react-native-safe-area-context' 6 - import {View} from 'react-native' 7 import {msg, Trans} from '@lingui/macro' 8 import {useLingui} from '@lingui/react' 9 - import {getCurrentRoute, isTab} from 'lib/routes/helpers' 10 - import {styles} from './BottomBarStyles' 11 - import {clamp} from 'lib/numbers' 12 import { 13 BellIcon, 14 BellIconSolid, 15 HomeIcon, 16 HomeIconSolid, 17 MagnifyingGlassIcon2, 18 MagnifyingGlassIcon2Solid, 19 - HashtagIcon, 20 UserIcon, 21 UserIconSolid, 22 - } from 'lib/icons' 23 - import {Link} from 'view/com/util/Link' 24 - import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' 25 - import {makeProfileLink} from 'lib/routes/links' 26 - import {CommonNavigatorParams} from 'lib/routes/types' 27 import {useSession} from '#/state/session' 28 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 29 import {useCloseAllActiveElements} from '#/state/util' 30 import {Button} from '#/view/com/util/forms/Button' 31 import {Text} from '#/view/com/util/text/Text' 32 - import {s} from 'lib/styles' 33 import {Logo} from '#/view/icons/Logo' 34 import {Logotype} from '#/view/icons/Logotype' 35 36 export function BottomBarWeb() { 37 const {_} = useLingui() ··· 41 const {footerMinimalShellTransform} = useMinimalShellMode() 42 const {requestSwitchToAccount} = useLoggedOutViewControls() 43 const closeAllActiveElements = useCloseAllActiveElements() 44 45 const showSignIn = React.useCallback(() => { 46 closeAllActiveElements() ··· 117 ) 118 }} 119 </NavItem> 120 <NavItem 121 routeName="Profile" 122 href={
··· 1 import React from 'react' 2 + import {View} from 'react-native' 3 import Animated from 'react-native-reanimated' 4 import {useSafeAreaInsets} from 'react-native-safe-area-context' 5 import {msg, Trans} from '@lingui/macro' 6 import {useLingui} from '@lingui/react' 7 + import {useNavigationState} from '@react-navigation/native' 8 + 9 + import {useMinimalShellMode} from '#/lib/hooks/useMinimalShellMode' 10 + import {usePalette} from '#/lib/hooks/usePalette' 11 import { 12 BellIcon, 13 BellIconSolid, 14 + HashtagIcon, 15 HomeIcon, 16 HomeIconSolid, 17 MagnifyingGlassIcon2, 18 MagnifyingGlassIcon2Solid, 19 UserIcon, 20 UserIconSolid, 21 + } from '#/lib/icons' 22 + import {clamp} from '#/lib/numbers' 23 + import {getCurrentRoute, isTab} from '#/lib/routes/helpers' 24 + import {makeProfileLink} from '#/lib/routes/links' 25 + import {CommonNavigatorParams} from '#/lib/routes/types' 26 + import {useGate} from '#/lib/statsig/statsig' 27 + import {s} from '#/lib/styles' 28 import {useSession} from '#/state/session' 29 import {useLoggedOutViewControls} from '#/state/shell/logged-out' 30 import {useCloseAllActiveElements} from '#/state/util' 31 import {Button} from '#/view/com/util/forms/Button' 32 import {Text} from '#/view/com/util/text/Text' 33 import {Logo} from '#/view/icons/Logo' 34 import {Logotype} from '#/view/icons/Logotype' 35 + import {Link} from 'view/com/util/Link' 36 + import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' 37 + import {Envelope_Filled_Stroke2_Corner0_Rounded as EnvelopeFilled} from '#/components/icons/Envelope' 38 + import {styles} from './BottomBarStyles' 39 40 export function BottomBarWeb() { 41 const {_} = useLingui() ··· 45 const {footerMinimalShellTransform} = useMinimalShellMode() 46 const {requestSwitchToAccount} = useLoggedOutViewControls() 47 const closeAllActiveElements = useCloseAllActiveElements() 48 + const gate = useGate() 49 50 const showSignIn = React.useCallback(() => { 51 closeAllActiveElements() ··· 122 ) 123 }} 124 </NavItem> 125 + {gate('dms') && ( 126 + <NavItem routeName="Messages" href="/messages"> 127 + {({isActive}) => { 128 + const Icon = isActive ? EnvelopeFilled : Envelope 129 + return ( 130 + <Icon 131 + size="lg" 132 + style={[styles.ctrlIcon, pal.text, styles.messagesIcon]} 133 + /> 134 + ) 135 + }} 136 + </NavItem> 137 + )} 138 <NavItem 139 routeName="Profile" 140 href={
+14
src/view/shell/desktop/LeftNav.tsx
··· 12 useNavigationState, 13 } from '@react-navigation/native' 14 15 import {isInvalidHandle} from '#/lib/strings/handles' 16 import {emitSoftReset} from '#/state/events' 17 import {useFetchHandle} from '#/state/queries/handle' ··· 46 import {PressableWithHover} from 'view/com/util/PressableWithHover' 47 import {Text} from 'view/com/util/text/Text' 48 import {UserAvatar} from 'view/com/util/UserAvatar' 49 import {router} from '../../../routes' 50 51 function ProfileCard() { ··· 272 const {_} = useLingui() 273 const {isDesktop, isTablet} = useWebMediaQueries() 274 const numUnread = useUnreadNotifications() 275 276 if (!hasSession && !isDesktop) { 277 return null ··· 346 } 347 label={_(msg`Notifications`)} 348 /> 349 <NavItem 350 href="/feeds" 351 icon={
··· 12 useNavigationState, 13 } from '@react-navigation/native' 14 15 + import {useGate} from '#/lib/statsig/statsig' 16 import {isInvalidHandle} from '#/lib/strings/handles' 17 import {emitSoftReset} from '#/state/events' 18 import {useFetchHandle} from '#/state/queries/handle' ··· 47 import {PressableWithHover} from 'view/com/util/PressableWithHover' 48 import {Text} from 'view/com/util/text/Text' 49 import {UserAvatar} from 'view/com/util/UserAvatar' 50 + import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope' 51 + import {Envelope_Filled_Stroke2_Corner0_Rounded as EnvelopeFilled} from '#/components/icons/Envelope' 52 import {router} from '../../../routes' 53 54 function ProfileCard() { ··· 275 const {_} = useLingui() 276 const {isDesktop, isTablet} = useWebMediaQueries() 277 const numUnread = useUnreadNotifications() 278 + const gate = useGate() 279 280 if (!hasSession && !isDesktop) { 281 return null ··· 350 } 351 label={_(msg`Notifications`)} 352 /> 353 + {gate('dms') && ( 354 + <NavItem 355 + href="/messages" 356 + icon={<Envelope style={pal.text} width={isDesktop ? 26 : 30} />} 357 + iconFilled={ 358 + <EnvelopeFilled style={pal.text} width={isDesktop ? 26 : 30} /> 359 + } 360 + label={_(msg`Messages`)} 361 + /> 362 + )} 363 <NavItem 364 href="/feeds" 365 icon={