feat: Update HTML `title` on web #626 #599 (#655)

For any `Screen` that shows on desktop, `title` is "(1) ... - Bluesky"
where "(1)" is the unread notification count.

The titles are unlocalized and the string "Bluesky" is hardcoded,
following the pattern of the rest of the app.

Display names and post content are loaded into the title as effects.

Tested:
* all screens
* screen changes / component mounts/unmounts
* long posts with links and images
* display name set/unset
* spamming myself with notifications, clearing notifications
* /profile/did:... links
* lint (only my changed files), jest, e2e.

New utilities: `useUnreadCountLabel`, `bskyTitle`,
`combinedDisplayName`, `useSetTitle`.

resolves: #626 #599
zio/stable
LW 2023-05-16 11:13:05 -07:00 committed by GitHub
parent a5838694bd
commit 50c1841a06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 180 additions and 21 deletions

View File

@ -28,6 +28,7 @@ import {isNative} from 'platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes'
import {usePalette} from 'lib/hooks/usePalette'
import {useUnreadCountLabel} from 'lib/hooks/useUnreadCountLabel'
import {useStores} from './state'
import {HomeScreen} from './view/screens/Home'
@ -55,6 +56,7 @@ import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {getRoutingInstrumentation} from 'lib/sentry'
import {bskyTitle} from 'lib/strings/headings'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@ -69,45 +71,120 @@ const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
/**
* These "common screens" are reused across stacks.
*/
function commonScreens(Stack: typeof HomeTab) {
function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
const title = (page: string) => bskyTitle(page, unreadCountLabel)
return (
<>
<Stack.Screen name="NotFound" component={NotFoundScreen} />
<Stack.Screen name="Moderation" component={ModerationScreen} />
<Stack.Screen
name="NotFound"
component={NotFoundScreen}
options={{title: title('Not Found')}}
/>
<Stack.Screen
name="Moderation"
component={ModerationScreen}
options={{title: title('Moderation')}}
/>
<Stack.Screen
name="ModerationMuteLists"
component={ModerationMuteListsScreen}
options={{title: title('Mute Lists')}}
/>
<Stack.Screen
name="ModerationMutedAccounts"
component={ModerationMutedAccounts}
options={{title: title('Muted Accounts')}}
/>
<Stack.Screen
name="ModerationBlockedAccounts"
component={ModerationBlockedAccounts}
options={{title: title('Blocked Accounts')}}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
options={{title: title('Settings')}}
/>
<Stack.Screen
name="Profile"
component={ProfileScreen}
options={({route}) => ({title: title(`@${route.params.name}`)})}
/>
<Stack.Screen name="Settings" component={SettingsScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
<Stack.Screen
name="ProfileFollowers"
component={ProfileFollowersScreen}
options={({route}) => ({
title: title(`People following @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileFollows"
component={ProfileFollowsScreen}
options={({route}) => ({
title: title(`People followed by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileList"
component={ProfileListScreen}
options={{title: title('Mute List')}}
/>
<Stack.Screen
name="PostThread"
component={PostThreadScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/>
<Stack.Screen
name="PostLikedBy"
component={PostLikedByScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/>
<Stack.Screen
name="PostRepostedBy"
component={PostRepostedByScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
/>
<Stack.Screen
name="Debug"
component={DebugScreen}
options={{title: title('Debug')}}
/>
<Stack.Screen
name="Log"
component={LogScreen}
options={{title: title('Log')}}
/>
<Stack.Screen
name="Support"
component={SupportScreen}
options={{title: title('Support')}}
/>
<Stack.Screen
name="PrivacyPolicy"
component={PrivacyPolicyScreen}
options={{title: title('Privacy Policy')}}
/>
<Stack.Screen
name="TermsOfService"
component={TermsOfServiceScreen}
options={{title: title('Terms of Service')}}
/>
<Stack.Screen name="ProfileFollows" component={ProfileFollowsScreen} />
<Stack.Screen name="ProfileList" component={ProfileListScreen} />
<Stack.Screen name="PostThread" component={PostThreadScreen} />
<Stack.Screen name="PostLikedBy" component={PostLikedByScreen} />
<Stack.Screen name="PostRepostedBy" component={PostRepostedByScreen} />
<Stack.Screen name="Debug" component={DebugScreen} />
<Stack.Screen name="Log" component={LogScreen} />
<Stack.Screen name="Support" component={SupportScreen} />
<Stack.Screen name="PrivacyPolicy" component={PrivacyPolicyScreen} />
<Stack.Screen name="TermsOfService" component={TermsOfServiceScreen} />
<Stack.Screen
name="CommunityGuidelines"
component={CommunityGuidelinesScreen}
options={{title: title('Community Guidelines')}}
/>
<Stack.Screen
name="CopyrightPolicy"
component={CopyrightPolicyScreen}
options={{title: title('Copyright Policy')}}
/>
<Stack.Screen
name="AppPasswords"
component={AppPasswords}
options={{title: title('App Passwords')}}
/>
<Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
<Stack.Screen name="AppPasswords" component={AppPasswords} />
</>
)
}
@ -221,6 +298,8 @@ const MyProfileTabNavigator = observer(() => {
*/
function FlatNavigator() {
const pal = usePalette('default')
const unreadCountLabel = useUnreadCountLabel()
const title = (page: string) => bskyTitle(page, unreadCountLabel)
return (
<Flat.Navigator
screenOptions={{
@ -230,10 +309,22 @@ function FlatNavigator() {
animationDuration: 250,
contentStyle: [pal.view],
}}>
<Flat.Screen name="Home" component={HomeScreen} />
<Flat.Screen name="Search" component={SearchScreen} />
<Flat.Screen name="Notifications" component={NotificationsScreen} />
{commonScreens(Flat as typeof HomeTab)}
<Flat.Screen
name="Home"
component={HomeScreen}
options={{title: title('Home')}}
/>
<Flat.Screen
name="Search"
component={SearchScreen}
options={{title: title('Search')}}
/>
<Flat.Screen
name="Notifications"
component={NotificationsScreen}
options={{title: title('Notifications')}}
/>
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
</Flat.Navigator>
)
}

View File

@ -0,0 +1,16 @@
import {useEffect} from 'react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings'
import {useUnreadCountLabel} from './useUnreadCountLabel'
export function useSetTitle(title?: string) {
const navigation = useNavigation<NavigationProp>()
const unreadCountLabel = useUnreadCountLabel()
useEffect(() => {
if (title) {
navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
}
}, [title, navigation, unreadCountLabel])
}

View File

@ -0,0 +1,19 @@
import {useEffect, useReducer} from 'react'
import {DeviceEventEmitter} from 'react-native'
import {useStores} from 'state/index'
export function useUnreadCountLabel() {
// HACK: We don't have anything like Redux selectors,
// and we don't want to use <RootStoreContext.Consumer />
// to react to the whole store
const [, forceUpdate] = useReducer(x => x + 1, 0)
useEffect(() => {
const subscription = DeviceEventEmitter.addListener(
'unread-notifications',
forceUpdate,
)
return () => subscription?.remove()
}, [forceUpdate])
return useStores().me.notifications.unreadCountLabel
}

View File

@ -10,3 +10,18 @@ export function sanitizeDisplayName(str: string): string {
}
return ''
}
export function combinedDisplayName({
handle,
displayName,
}: {
handle?: string
displayName?: string
}): string {
if (!handle) {
return ''
}
return displayName
? `${sanitizeDisplayName(displayName)} (@${handle})`
: `@${handle}`
}

View File

@ -0,0 +1,4 @@
export function bskyTitle(page: string, unreadCountLabel?: string) {
const unreadPrefix = unreadCountLabel ? `(${unreadCountLabel}) ` : ''
return `${unreadPrefix}${page} - Bluesky`
}

View File

@ -24,8 +24,10 @@ import {Text} from '../util/text/Text'
import {s} from 'lib/styles'
import {isDesktopWeb, isMobileWeb} from 'platform/detection'
import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {sanitizeDisplayName} from 'lib/strings/display-names'
const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false}
const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false}
@ -59,6 +61,13 @@ export const PostThread = observer(function PostThread({
}
return []
}, [view.thread])
useSetTitle(
view.thread?.postRecord &&
`${sanitizeDisplayName(
view.thread.post.author.displayName ||
`@${view.thread.post.author.handle}`,
)}: "${view.thread?.postRecord?.text}"`,
)
// events
// =

View File

@ -25,6 +25,8 @@ import {FAB} from '../com/util/fab/FAB'
import {s, colors} from 'lib/styles'
import {useAnalytics} from 'lib/analytics'
import {ComposeIcon2} from 'lib/icons'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {combinedDisplayName} from 'lib/strings/display-names'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'>
export const ProfileScreen = withAuthRequired(
@ -41,6 +43,7 @@ export const ProfileScreen = withAuthRequired(
() => new ProfileUiModel(store, {user: route.params.name}),
[route.params.name, store],
)
useSetTitle(combinedDisplayName(uiState.profile))
useFocusEffect(
React.useCallback(() => {

View File

@ -14,6 +14,7 @@ import * as Toast from 'view/com/util/Toast'
import {ListModel} from 'state/models/content/list'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useSetTitle} from 'lib/hooks/useSetTitle'
import {NavigationProp} from 'lib/routes/types'
import {isDesktopWeb} from 'platform/detection'
@ -32,6 +33,7 @@ export const ProfileListScreen = withAuthRequired(
)
return model
}, [store, name, rkey])
useSetTitle(list.list?.name)
useFocusEffect(
React.useCallback(() => {