Merge branch 'main' into 0.74-fabric

This commit is contained in:
Hailey
2024-07-28 09:29:59 -07:00
356 changed files with 32536 additions and 20810 deletions
+47 -34
View File
@@ -23,10 +23,12 @@ import {
} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {listenSessionDropped} from '#/state/events'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
@@ -45,9 +47,11 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
@@ -55,9 +59,8 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
SplashScreen.preventAutoHideAsync()
@@ -93,7 +96,10 @@ function InnerApp() {
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
Toast.show(
_(msg`Sorry! Your session expired. Please log in again.`),
'info',
)
})
}, [_])
@@ -102,37 +108,44 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
<ActiveVideoProvider>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<TourProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</ActiveVideoProvider>
</Splash>
</ThemeProvider>
</Alf>
+44 -32
View File
@@ -12,10 +12,12 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {listenSessionDropped} from '#/state/events'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
@@ -34,8 +36,10 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
@@ -43,9 +47,8 @@ import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -75,7 +78,10 @@ function InnerApp() {
useEffect(() => {
return listenSessionDropped(() => {
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
Toast.show(
_(msg`Sorry! Your session expired. Please log in again.`),
'info',
)
})
}, [_])
@@ -87,35 +93,41 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
<ActiveVideoProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<TourProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</ActiveVideoProvider>
</RootSiblingParent>
</ThemeProvider>
</Alf>
+35 -3
View File
@@ -31,7 +31,7 @@ import {
} from 'lib/routes/types'
import {RouteParams, State} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings'
import {isAndroid, isNative} from 'platform/detection'
import {isAndroid, isNative, isWeb} from 'platform/detection'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
@@ -39,12 +39,17 @@ import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag'
import {ModerationScreen} from '#/screens/Moderation'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {StarterPackScreen} from '#/screens/StarterPack/StarterPackScreen'
import {
StarterPackScreen,
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
@@ -71,6 +76,7 @@ import {LogScreen} from './view/screens/Log'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound'
import {NotificationsScreen} from './view/screens/Notifications'
import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {PostThreadScreen} from './view/screens/PostThread'
@@ -230,6 +236,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => DebugModScreen}
options={{title: title(msg`Moderation states`), requireAuth: true}}
/>
<Stack.Screen
name="SharedPreferencesTester"
getComponent={() => SharedPreferencesTesterScreen}
options={{title: title(msg`Shared Preferences Tester`)}}
/>
<Stack.Screen
name="Log"
getComponent={() => LogScreen}
@@ -314,6 +325,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="NotificationsSettings"
getComponent={() => NotificationsSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}}
/>
<Stack.Screen
name="Feeds"
getComponent={() => FeedsScreen}
@@ -322,7 +338,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
<Stack.Screen
name="StarterPack"
getComponent={() => StarterPackScreen}
options={{title: title(msg`Starter Pack`), requireAuth: true}}
options={{title: title(msg`Starter Pack`)}}
/>
<Stack.Screen
name="StarterPackShort"
getComponent={() => StarterPackScreenShort}
options={{title: title(msg`Starter Pack`)}}
/>
<Stack.Screen
name="StarterPackWizard"
@@ -755,6 +776,17 @@ function logModuleInitTime() {
initMs,
})
if (isWeb) {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
logEvent('deepLink:referrerReceived', {
to: window.location.href,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
}
if (__DEV__) {
// This log is noisy, so keep false committed
const shouldLog = false
+8 -6
View File
@@ -2,7 +2,6 @@ import {Platform, StyleSheet} from 'react-native'
import * as tokens from '#/alf/tokens'
import {native, web} from '#/alf/util/platform'
import hairlineWidth = StyleSheet.hairlineWidth
export const atoms = {
/*
@@ -145,6 +144,9 @@ export const atoms = {
flex_shrink: {
flexShrink: 1,
},
flex_shrink_0: {
flexShrink: 0,
},
justify_start: {
justifyContent: 'flex-start',
},
@@ -281,19 +283,19 @@ export const atoms = {
borderWidth: 0,
},
border: {
borderWidth: hairlineWidth,
borderWidth: StyleSheet.hairlineWidth,
},
border_t: {
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
},
border_b: {
borderBottomWidth: hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
},
border_l: {
borderLeftWidth: hairlineWidth,
borderLeftWidth: StyleSheet.hairlineWidth,
},
border_r: {
borderRightWidth: hairlineWidth,
borderRightWidth: StyleSheet.hairlineWidth,
},
/*
+16 -5
View File
@@ -1,7 +1,9 @@
import React from 'react'
import {Dimensions} from 'react-native'
import * as themes from '#/alf/themes'
import {createThemes, defaultTheme} from '#/alf/themes'
import {Theme, ThemeName} from '#/alf/types'
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
export {atoms} from '#/alf/atoms'
export * as tokens from '#/alf/tokens'
@@ -39,8 +41,8 @@ function getActiveBreakpoints({width}: {width: number}) {
* Context
*/
export const Context = React.createContext<{
themeName: themes.ThemeName
theme: themes.Theme
themeName: ThemeName
theme: Theme
breakpoints: {
active: BreakpointName | undefined
gtPhone: boolean
@@ -49,7 +51,7 @@ export const Context = React.createContext<{
}
}>({
themeName: 'light',
theme: themes.light,
theme: defaultTheme,
breakpoints: {
active: undefined,
gtPhone: false,
@@ -61,7 +63,16 @@ export const Context = React.createContext<{
export function ThemeProvider({
children,
theme: themeName,
}: React.PropsWithChildren<{theme: themes.ThemeName}>) {
}: React.PropsWithChildren<{theme: ThemeName}>) {
const themes = React.useMemo(() => {
return createThemes({
hues: {
primary: BLUE_HUE,
negative: RED_HUE,
positive: GREEN_HUE,
},
})
}, [])
const theme = themes[themeName]
const [breakpoints, setBreakpoints] = React.useState(() =>
getActiveBreakpoints({width: Dimensions.get('window').width}),
+569 -447
View File
File diff suppressed because it is too large Load Diff
-78
View File
@@ -1,77 +1,6 @@
import {
BLUE_HUE,
generateScale,
GREEN_HUE,
RED_HUE,
} from '#/alf/util/colorGeneration'
export const scale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
export const color = {
trueBlack: '#000000',
temp_purple: 'rgb(105 0 255)',
temp_purple_dark: 'rgb(83 0 202)',
gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
gray_100: `hsl(${BLUE_HUE}, 20%, ${scale[11]}%)`,
gray_200: `hsl(${BLUE_HUE}, 20%, ${scale[10]}%)`,
gray_300: `hsl(${BLUE_HUE}, 20%, ${scale[9]}%)`,
gray_400: `hsl(${BLUE_HUE}, 20%, ${scale[8]}%)`,
gray_500: `hsl(${BLUE_HUE}, 20%, ${scale[7]}%)`,
gray_600: `hsl(${BLUE_HUE}, 24%, ${scale[6]}%)`,
gray_700: `hsl(${BLUE_HUE}, 24%, ${scale[5]}%)`,
gray_800: `hsl(${BLUE_HUE}, 28%, ${scale[4]}%)`,
gray_900: `hsl(${BLUE_HUE}, 28%, ${scale[3]}%)`,
gray_950: `hsl(${BLUE_HUE}, 28%, ${scale[2]}%)`,
gray_975: `hsl(${BLUE_HUE}, 28%, ${scale[1]}%)`,
gray_1000: `hsl(${BLUE_HUE}, 28%, ${scale[0]}%)`,
blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
blue_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
blue_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
blue_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
blue_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
blue_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
blue_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
blue_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
blue_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
blue_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
green_900: `hsl(${GREEN_HUE}, 82%, 18%)`,
green_950: `hsl(${GREEN_HUE}, 82%, 10%)`,
green_975: `hsl(${GREEN_HUE}, 82%, 7%)`,
red_25: `hsl(${RED_HUE}, 91%, 97%)`,
red_50: `hsl(${RED_HUE}, 91%, 95%)`,
red_100: `hsl(${RED_HUE}, 91%, 90%)`,
red_200: `hsl(${RED_HUE}, 91%, 80%)`,
red_300: `hsl(${RED_HUE}, 91%, 70%)`,
red_400: `hsl(${RED_HUE}, 91%, 60%)`,
red_500: `hsl(${RED_HUE}, 91%, 50%)`,
red_600: `hsl(${RED_HUE}, 91%, 42%)`,
red_700: `hsl(${RED_HUE}, 91%, 34%)`,
red_800: `hsl(${RED_HUE}, 91%, 26%)`,
red_900: `hsl(${RED_HUE}, 91%, 18%)`,
red_950: `hsl(${RED_HUE}, 91%, 10%)`,
red_975: `hsl(${RED_HUE}, 91%, 7%)`,
} as const
export const space = {
@@ -178,10 +107,3 @@ export const gradients = {
hover_value: '#755B62',
},
} as const
export type Color = keyof typeof color
export type Space = keyof typeof space
export type FontSize = keyof typeof fontSize
export type LineHeight = keyof typeof lineHeight
export type BorderRadius = keyof typeof borderRadius
export type FontWeight = keyof typeof fontWeight
+154 -18
View File
@@ -1,21 +1,4 @@
import {StyleProp, ViewStyle, TextStyle} from 'react-native'
type LiteralToCommon<T extends PropertyKey> = T extends number
? number
: T extends string
? string
: T extends symbol
? symbol
: never
/**
* @see https://stackoverflow.com/questions/68249999/use-as-const-in-typescript-without-adding-readonly-modifiers
*/
export type Mutable<T> = {
-readonly [K in keyof T]: T[K] extends PropertyKey
? LiteralToCommon<T[K]>
: Mutable<T[K]>
}
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
export type TextStyleProp = {
style?: StyleProp<TextStyle>
@@ -24,3 +7,156 @@ export type TextStyleProp = {
export type ViewStyleProp = {
style?: StyleProp<ViewStyle>
}
export type ThemeName = 'light' | 'dim' | 'dark'
export type Palette = {
white: string
black: string
contrast_25: string
contrast_50: string
contrast_100: string
contrast_200: string
contrast_300: string
contrast_400: string
contrast_500: string
contrast_600: string
contrast_700: string
contrast_800: string
contrast_900: string
contrast_950: string
contrast_975: string
primary_25: string
primary_50: string
primary_100: string
primary_200: string
primary_300: string
primary_400: string
primary_500: string
primary_600: string
primary_700: string
primary_800: string
primary_900: string
primary_950: string
primary_975: string
positive_25: string
positive_50: string
positive_100: string
positive_200: string
positive_300: string
positive_400: string
positive_500: string
positive_600: string
positive_700: string
positive_800: string
positive_900: string
positive_950: string
positive_975: string
negative_25: string
negative_50: string
negative_100: string
negative_200: string
negative_300: string
negative_400: string
negative_500: string
negative_600: string
negative_700: string
negative_800: string
negative_900: string
negative_950: string
negative_975: string
}
export type ThemedAtoms = {
text: {
color: string
}
text_contrast_low: {
color: string
}
text_contrast_medium: {
color: string
}
text_contrast_high: {
color: string
}
text_inverted: {
color: string
}
bg: {
backgroundColor: string
}
bg_contrast_25: {
backgroundColor: string
}
bg_contrast_50: {
backgroundColor: string
}
bg_contrast_100: {
backgroundColor: string
}
bg_contrast_200: {
backgroundColor: string
}
bg_contrast_300: {
backgroundColor: string
}
bg_contrast_400: {
backgroundColor: string
}
bg_contrast_500: {
backgroundColor: string
}
bg_contrast_600: {
backgroundColor: string
}
bg_contrast_700: {
backgroundColor: string
}
bg_contrast_800: {
backgroundColor: string
}
bg_contrast_900: {
backgroundColor: string
}
bg_contrast_950: {
backgroundColor: string
}
bg_contrast_975: {
backgroundColor: string
}
border_contrast_low: {
borderColor: string
}
border_contrast_medium: {
borderColor: string
}
border_contrast_high: {
borderColor: string
}
shadow_sm: {
shadowRadius: number
shadowOpacity: number
elevation: number
shadowColor: string
}
shadow_md: {
shadowRadius: number
shadowOpacity: number
elevation: number
shadowColor: string
}
shadow_lg: {
shadowRadius: number
shadowOpacity: number
elevation: number
shadowColor: string
}
}
export type Theme = {
name: ThemeName
palette: Palette
atoms: ThemedAtoms
}
+4
View File
@@ -15,3 +15,7 @@ export function generateScale(start: number, end: number) {
return start + range * stop
})
}
export const defaultScale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
+1 -1
View File
@@ -1,4 +1,4 @@
import {ThemeName} from '#/alf/themes'
import {ThemeName} from '#/alf/types'
export function select<T>(name: ThemeName, options: Record<ThemeName, T>) {
switch (name) {
+2 -1
View File
@@ -4,7 +4,8 @@ import * as SystemUI from 'expo-system-ui'
import {isWeb} from 'platform/detection'
import {useThemePrefs} from 'state/shell'
import {dark, dim, light, ThemeName} from '#/alf/themes'
import {dark, dim, light} from '#/alf/themes'
import {ThemeName} from '#/alf/types'
export function useColorModeTheme(): ThemeName {
const theme = useThemeName()
+107 -27
View File
@@ -13,7 +13,7 @@ import {
} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {android, atoms as a, flatten, tokens, useTheme} from '#/alf'
import {android, atoms as a, flatten, select, tokens, useTheme} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
import {normalizeTextStyles} from '#/components/Typography'
@@ -21,6 +21,7 @@ export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
export type ButtonColor =
| 'primary'
| 'secondary'
| 'secondary_inverted'
| 'negative'
| 'gradient_sky'
| 'gradient_midnight'
@@ -69,6 +70,9 @@ export type ButtonProps = Pick<
AccessibilityProps &
VariantProps & {
testID?: string
/**
* For a11y, try to make this descriptive and clear
*/
label: string
style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle>
@@ -151,7 +155,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
@@ -164,7 +167,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
})
} else {
baseStyles.push({
backgroundColor: t.palette.primary_700,
backgroundColor: select(t.name, {
light: t.palette.primary_700,
dim: t.palette.primary_300,
dark: t.palette.primary_300,
}),
})
}
} else if (variant === 'outline') {
@@ -177,24 +184,18 @@ export const Button = React.forwardRef<View, ButtonProps>(
borderColor: t.palette.primary_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
? t.palette.primary_50
: t.palette.primary_950,
backgroundColor: t.palette.primary_50,
})
} else {
baseStyles.push(a.border, {
borderColor: light
? t.palette.primary_200
: t.palette.primary_900,
borderColor: t.palette.primary_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.primary_100
: t.palette.primary_900,
backgroundColor: t.palette.primary_100,
})
}
}
@@ -202,14 +203,63 @@ export const Button = React.forwardRef<View, ButtonProps>(
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.contrast_25,
backgroundColor: select(t.name, {
light: t.palette.contrast_25,
dim: t.palette.contrast_100,
dark: t.palette.contrast_100,
}),
})
hoverStyles.push({
backgroundColor: t.palette.contrast_50,
backgroundColor: select(t.name, {
light: t.palette.contrast_50,
dim: t.palette.contrast_200,
dark: t.palette.contrast_200,
}),
})
} else {
baseStyles.push({
backgroundColor: t.palette.contrast_100,
backgroundColor: select(t.name, {
light: t.palette.contrast_100,
dim: t.palette.contrast_25,
dark: t.palette.contrast_25,
}),
})
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.contrast_300,
})
hoverStyles.push(t.atoms.bg_contrast_50)
} else {
baseStyles.push(a.border, {
borderColor: t.palette.contrast_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: t.palette.contrast_25,
})
}
}
} else if (color === 'secondary_inverted') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.contrast_900,
})
hoverStyles.push({
backgroundColor: t.palette.contrast_950,
})
} else {
baseStyles.push({
backgroundColor: t.palette.contrast_600,
})
}
} else if (variant === 'outline') {
@@ -246,7 +296,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
})
} else {
baseStyles.push({
backgroundColor: t.palette.negative_700,
backgroundColor: select(t.name, {
light: t.palette.negative_700,
dim: t.palette.negative_300,
dark: t.palette.negative_300,
}),
})
}
} else if (variant === 'outline') {
@@ -259,24 +313,18 @@ export const Button = React.forwardRef<View, ButtonProps>(
borderColor: t.palette.negative_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
? t.palette.negative_50
: t.palette.negative_975,
backgroundColor: t.palette.negative_50,
})
} else {
baseStyles.push(a.border, {
borderColor: light
? t.palette.negative_200
: t.palette.negative_900,
borderColor: t.palette.negative_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.negative_100
: t.palette.negative_975,
backgroundColor: t.palette.negative_100,
})
}
}
@@ -344,6 +392,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
const gradient = {
primary: tokens.gradients.sky,
secondary: tokens.gradients.sky,
secondary_inverted: tokens.gradients.sky,
negative: tokens.gradients.sky,
gradient_sky: tokens.gradients.sky,
gradient_midnight: tokens.gradients.midnight,
@@ -443,7 +492,6 @@ export function useSharedButtonTextStyles() {
const {color, variant, disabled, size} = useButtonContext()
return React.useMemo(() => {
const baseStyles: TextStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
@@ -455,7 +503,7 @@ export function useSharedButtonTextStyles() {
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: light ? t.palette.primary_600 : t.palette.primary_500,
color: t.palette.primary_600,
})
} else {
baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
@@ -499,6 +547,38 @@ export function useSharedButtonTextStyles() {
})
}
}
} else if (color === 'secondary_inverted') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({
color: t.palette.contrast_100,
})
} else {
baseStyles.push({
color: t.palette.contrast_400,
})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: t.palette.contrast_600,
})
} else {
baseStyles.push({
color: t.palette.contrast_300,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({
color: t.palette.contrast_600,
})
} else {
baseStyles.push({
color: t.palette.contrast_300,
})
}
}
} else if (color === 'negative') {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
+4 -1
View File
@@ -88,7 +88,10 @@ export function Outer({
if (!isOpen) return
function handler(e: KeyboardEvent) {
if (e.key === 'Escape') close()
if (e.key === 'Escape') {
e.stopPropagation()
close()
}
}
document.addEventListener('keydown', handler)
+73 -83
View File
@@ -5,6 +5,7 @@ import {
AppBskyFeedDefs,
AppBskyGraphDefs,
AtUri,
RichText as RichTextApi,
} from '@atproto/api'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -17,94 +18,74 @@ import {
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {sanitizeHandle} from 'lib/strings/handles'
import {precacheFeedFromGeneratorView, precacheList} from 'state/queries/feed'
import {precacheFeedFromGeneratorView} from 'state/queries/feed'
import {useSession} from 'state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {useRichText} from '#/components/hooks/useRichText'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {RichText, RichTextProps} from '#/components/RichText'
import {Text} from '#/components/Typography'
type Props =
| {
type: 'feed'
view: AppBskyFeedDefs.GeneratorView
}
| {
type: 'list'
view: AppBskyGraphDefs.ListView
}
type Props = {
view: AppBskyFeedDefs.GeneratorView
}
export function Default(props: Props) {
const {type, view} = props
const displayName = type === 'feed' ? view.displayName : view.name
const purpose = type === 'list' ? view.purpose : undefined
const {view} = props
return (
<Link label={displayName} {...props}>
<Link {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline
title={displayName}
creator={view.creator}
type={type}
purpose={purpose}
/>
<Action uri={view.uri} pin type={type} purpose={purpose} />
<TitleAndByline title={view.displayName} creator={view.creator} />
<SaveButton view={view} pin />
</Header>
<Description description={view.description} />
{type === 'feed' && <Likes count={view.likeCount || 0} />}
<Likes count={view.likeCount || 0} />
</Outer>
</Link>
)
}
export function Link({
type,
view,
label,
children,
}: Props & Omit<LinkProps, 'to'>) {
...props
}: Props & Omit<LinkProps, 'to' | 'label'>) {
const queryClient = useQueryClient()
const href = React.useMemo(() => {
return createProfileFeedHref({feed: view})
}, [view])
React.useEffect(() => {
precacheFeedFromGeneratorView(queryClient, view)
}, [view, queryClient])
return (
<InternalLink
label={view.displayName}
to={href}
label={label}
onPress={() => {
if (type === 'feed') {
precacheFeedFromGeneratorView(queryClient, view)
} else {
precacheList(queryClient, view)
}
}}>
style={[a.flex_col]}
{...props}>
{children}
</InternalLink>
)
}
export function Outer({children}: {children: React.ReactNode}) {
return <View style={[a.flex_1, a.gap_md]}>{children}</View>
return <View style={[a.w_full, a.gap_md]}>{children}</View>
}
export function Header({children}: {children: React.ReactNode}) {
return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_md]}>
{children}
</View>
)
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
}
export type AvatarProps = {src: string | undefined; size?: number}
@@ -132,13 +113,9 @@ export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
export function TitleAndByline({
title,
creator,
type,
purpose,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
type: 'feed' | 'list'
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const t = useTheme()
@@ -151,15 +128,7 @@ export function TitleAndByline({
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{type === 'list' && purpose === 'app.bsky.graph.defs#curatelist' ? (
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
) : type === 'list' && purpose === 'app.bsky.graph.defs#modlist' ? (
<Trans>
Moderation list by {sanitizeHandle(creator.handle, '@')}
</Trans>
) : (
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
)}
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
</Text>
)}
</View>
@@ -198,13 +167,39 @@ export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
)
}
export function Description({description}: {description?: string}) {
const [rt, isResolving] = useRichText(description || '')
if (!description) return null
return isResolving ? (
<RichText value={description} style={[a.leading_snug]} />
) : (
<RichText value={rt} style={[a.leading_snug]} />
export function Description({
description,
...rest
}: {description?: string} & Partial<RichTextProps>) {
const rt = React.useMemo(() => {
if (!description) return
const rt = new RichTextApi({text: description || ''})
rt.detectFacetsWithoutResolution()
return rt
}, [description])
if (!rt) return null
return <RichText value={rt} style={[a.leading_snug]} disableLinks {...rest} />
}
export function DescriptionPlaceholder() {
const t = useTheme()
return (
<View style={[a.gap_xs]}>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
{height: 12, width: 100},
]}
/>
</View>
)
}
@@ -220,30 +215,24 @@ export function Likes({count}: {count: number}) {
)
}
export function Action({
uri,
export function SaveButton({
view,
pin,
type,
purpose,
}: {
uri: string
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
pin?: boolean
type: 'feed' | 'list'
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const {hasSession} = useSession()
if (!hasSession || purpose !== 'app.bsky.graph.defs#curatelist') return null
return <ActionInner uri={uri} pin={pin} type={type} />
if (!hasSession) return null
return <SaveButtonInner view={view} pin={pin} />
}
function ActionInner({
uri,
function SaveButtonInner({
view,
pin,
type,
}: {
uri: string
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
pin?: boolean
type: 'feed' | 'list'
}) {
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
@@ -251,6 +240,10 @@ function ActionInner({
useAddSavedFeedsMutation()
const {isPending: isRemovePending, mutateAsync: removeFeed} =
useRemoveFeedMutation()
const uri = view.uri
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
const savedFeedConfig = React.useMemo(() => {
return preferences?.savedFeeds?.find(feed => feed.value === uri)
}, [preferences?.savedFeeds, uri])
@@ -275,9 +268,9 @@ function ActionInner({
])
}
Toast.show(_(msg`Feeds updated!`))
} catch (e: any) {
logger.error(e, {context: `FeedCard: failed to update feeds`, pin})
Toast.show(_(msg`Failed to update feeds`))
} catch (err: any) {
logger.error(err, {context: `FeedCard: failed to update feeds`, pin})
Toast.show(_(msg`Failed to update feeds`), 'xmark')
}
},
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
@@ -327,12 +320,9 @@ function ActionInner({
export function createProfileFeedHref({
feed,
}: {
feed: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
feed: AppBskyFeedDefs.GeneratorView
}) {
const urip = new AtUri(feed.uri)
const type = urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'list'
const handleOrDid = feed.creator.handle || feed.creator.did
return `/profile/${handleOrDid}/${type === 'feed' ? 'feed' : 'lists'}/${
urip.rkey
}`
return `/profile/${handleOrDid}/feed/${urip.rkey}`
}
+466
View File
@@ -0,0 +1,466 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {useProfilesQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide'
import * as userActionHistory from '#/state/userActionHistory'
import {SeenPost} from '#/state/userActionHistory'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
import {Button} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard'
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {ProgressGuideList} from './ProgressGuide/List'
const MOBILE_CARD_WIDTH = 300
function CardOuter({
children,
style,
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
a.w_full,
a.p_lg,
a.rounded_md,
a.border,
t.atoms.bg,
t.atoms.border_contrast_low,
!gtMobile && {
width: MOBILE_CARD_WIDTH,
},
style,
]}>
{children}
</View>
)
}
export function SuggestedFollowPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
</ProfileCard.Header>
<View style={[a.py_xs]}>
<ProfileCard.NameAndHandlePlaceholder />
</View>
<ProfileCard.DescriptionPlaceholder />
</CardOuter>
)
}
export function SuggestedFeedsCardPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<FeedCard.Header>
<FeedCard.AvatarPlaceholder />
<FeedCard.TitleAndBylinePlaceholder creator />
</FeedCard.Header>
<FeedCard.DescriptionPlaceholder />
</CardOuter>
)
}
function getRank(seenPost: SeenPost): string {
let tier: string
if (seenPost.feedContext === 'popfriends') {
tier = 'a'
} else if (seenPost.feedContext?.startsWith('cluster')) {
tier = 'b'
} else if (seenPost.feedContext?.startsWith('ntpc')) {
tier = 'c'
} else if (seenPost.feedContext?.startsWith('t-')) {
tier = 'd'
} else if (seenPost.feedContext === 'nettop') {
tier = 'e'
} else {
tier = 'f'
}
let score = Math.round(
Math.log(
1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
),
)
if (seenPost.isFollowedBy || Math.random() > 0.9) {
score *= 2
}
const rank = 100 - score
return `${tier}-${rank}`
}
function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
const rankA = getRank(postA)
const rankB = getRank(postB)
// Yes, we're comparing strings here.
// The "larger" string means a worse rank.
if (rankA > rankB) {
return 1
} else if (rankA < rankB) {
return -1
} else {
return 0
}
}
function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => {
const {likes, follows, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
.map(uri => uri.host)
.filter(did => !follows.includes(did))
const seenDids = seen
.sort(sortSeenPosts)
.map(l => new AtUri(l.uri))
.map(uri => uri.host)
return [...new Set([...likeDids, ...seenDids])].filter(
did => did !== currentAccount?.did,
)
}, [userActionSnapshot, currentAccount])
const {data, isLoading, error} = useProfilesQuery({
handles: dids.slice(0, 16),
})
const profiles = data
? data.profiles.filter(profile => {
return !profile.viewer?.following
})
: []
return {
isLoading,
error,
profiles: profiles.slice(0, 6),
}
}
export function SuggestedFollows() {
const t = useTheme()
const {_} = useLingui()
const {
isLoading: isSuggestionsLoading,
profiles,
error,
} = useExperimentalSuggestedUsersQuery()
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const isLoading = isSuggestionsLoading || !moderationOpts
const maxLength = gtMobile ? 4 : 6
const content = isLoading ? (
Array(maxLength)
.fill(0)
.map((_, i) => (
<View
key={i}
style={[gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}])]}>
<SuggestedFollowPlaceholder />
</View>
))
) : error || !profiles.length ? null : (
<>
{profiles.slice(0, maxLength).map(profile => (
<ProfileCard.Link
key={profile.did}
profile={profile}
onPress={() => {
logEvent('feed:interstitial:profileCard:press', {})
}}
style={[
a.flex_1,
gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]),
]}>
{({hovered, pressed}) => (
<CardOuter
style={[
a.flex_1,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="FeedInterstitial"
color="secondary_inverted"
shape="round"
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} />
</ProfileCard.Outer>
</CardOuter>
)}
</ProfileCard.Link>
))}
</>
)
if (error || (!isLoading && profiles.length < 4)) {
logger.debug(`Not enough profiles to show suggested follows`)
return null
}
return (
<View
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
<Text
style={[
a.flex_1,
a.text_lg,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Suggested for you</Trans>
</Text>
<Person fill={t.atoms.text_contrast_low.color} />
</View>
{gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
</View>
<View
style={[
a.flex_row,
a.justify_end,
a.align_center,
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText
label={_(msg`Browse more suggestions`)}
to="/search"
style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more accounts on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}>
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on the Explore page</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
)}
</View>
)
}
export function SuggestedFeeds() {
const numFeedsToDisplay = 3
const t = useTheme()
const {_} = useLingui()
const {data, isLoading, error} = useGetPopularFeedsQuery({
limit: numFeedsToDisplay,
})
const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints()
const feeds = React.useMemo(() => {
const items: AppBskyFeedDefs.GeneratorView[] = []
if (!data) return items
for (const page of data.pages) {
for (const feed of page.feeds) {
items.push(feed)
}
}
return items
}, [data])
const content = isLoading ? (
Array(numFeedsToDisplay)
.fill(0)
.map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
) : error || !feeds ? null : (
<>
{feeds.slice(0, numFeedsToDisplay).map(feed => (
<FeedCard.Link
key={feed.uri}
view={feed}
onPress={() => {
logEvent('feed:interstitial:feedCard:press', {})
}}>
{({hovered, pressed}) => (
<CardOuter
style={[
a.flex_1,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<FeedCard.Outer>
<FeedCard.Header>
<FeedCard.Avatar src={feed.avatar} />
<FeedCard.TitleAndByline
title={feed.displayName}
creator={feed.creator}
/>
</FeedCard.Header>
<FeedCard.Description
description={feed.description}
numberOfLines={3}
/>
</FeedCard.Outer>
</CardOuter>
)}
</FeedCard.Link>
))}
</>
)
return error ? null : (
<View
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
<Text
style={[
a.flex_1,
a.text_lg,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Some other feeds you might like</Trans>
</Text>
<Hashtag fill={t.atoms.text_contrast_low.color} />
</View>
{gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
{content}
<View
style={[
a.flex_row,
a.justify_end,
a.align_center,
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText
label={_(msg`Browse more suggestions`)}
to="/search"
style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
</View>
</View>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
{content}
<Button
label={_(msg`Browse more feeds on the Explore page`)}
onPress={() => {
navigation.navigate('SearchTab')
}}
style={[a.flex_col]}>
<CardOuter style={[a.flex_1]}>
<View style={[a.flex_1, a.justify_center]}>
<View style={[a.flex_row, a.px_lg]}>
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
<Trans>Browse more suggestions on the Explore page</Trans>
</Text>
<Arrow size="xl" />
</View>
</View>
</CardOuter>
</Button>
</View>
</ScrollView>
)}
</View>
)
}
export function ProgressGuide() {
const t = useTheme()
const {isDesktop} = useWebMediaQueries()
const guide = useProgressGuide('like-10-and-follow-7')
if (isDesktop) {
return null
}
return guide ? (
<View
style={[
a.border_t,
t.atoms.border_contrast_low,
a.px_lg,
a.py_lg,
a.pb_lg,
]}>
<ProgressGuideList />
</View>
) : null
}
+5 -6
View File
@@ -2,14 +2,14 @@ import React from 'react'
import {View} from 'react-native'
import {
useTheme,
atoms as a,
ViewStyleProp,
TextStyleProp,
flatten,
TextStyleProp,
useTheme,
ViewStyleProp,
} from '#/alf'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
import {Props} from '#/components/icons/common'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
export function IconCircle({
icon: Icon,
@@ -32,8 +32,7 @@ export function IconCircle({
{
width: size === 'lg' ? 52 : 64,
height: size === 'lg' ? 52 : 64,
backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
backgroundColor: t.palette.primary_50,
},
flatten(style),
]}>
+14 -6
View File
@@ -12,6 +12,7 @@ import {Link, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
const AVI_SIZE = 30
const AVI_SIZE_SMALL = 20
const AVI_BORDER = 1
/**
@@ -30,10 +31,12 @@ export function KnownFollowers({
profile,
moderationOpts,
onLinkPress,
minimal,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
onLinkPress?: LinkProps['onPress']
minimal?: boolean
}) {
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
new Map(),
@@ -59,6 +62,7 @@ export function KnownFollowers({
cachedKnownFollowers={cachedKnownFollowers}
moderationOpts={moderationOpts}
onLinkPress={onLinkPress}
minimal={minimal}
/>
)
}
@@ -71,11 +75,13 @@ function KnownFollowersInner({
moderationOpts,
cachedKnownFollowers,
onLinkPress,
minimal,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
onLinkPress?: LinkProps['onPress']
minimal?: boolean
}) {
const t = useTheme()
const {_} = useLingui()
@@ -110,6 +116,8 @@ function KnownFollowersInner({
*/
if (slice.length === 0) return null
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
return (
<Link
label={_(
@@ -120,7 +128,7 @@ function KnownFollowersInner({
style={[
a.flex_1,
a.flex_row,
a.gap_md,
minimal ? a.gap_sm : a.gap_md,
a.align_center,
{marginLeft: -AVI_BORDER},
]}>
@@ -129,8 +137,8 @@ function KnownFollowersInner({
<View
style={[
{
height: AVI_SIZE,
width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap,
height: SIZE,
width: SIZE + (slice.length - 1) * a.gap_md.gap,
},
pressed && {
opacity: 0.5,
@@ -145,14 +153,14 @@ function KnownFollowersInner({
{
borderWidth: AVI_BORDER,
borderColor: t.atoms.bg.backgroundColor,
width: AVI_SIZE + AVI_BORDER * 2,
height: AVI_SIZE + AVI_BORDER * 2,
width: SIZE + AVI_BORDER * 2,
height: SIZE + AVI_BORDER * 2,
left: i * a.gap_md.gap,
zIndex: AVI_BORDER - i,
},
]}>
<UserAvatar
size={AVI_SIZE}
size={SIZE}
avatar={prof.avatar}
moderation={moderation.ui('avatar')}
/>
+52 -10
View File
@@ -1,5 +1,10 @@
import React from 'react'
import {GestureResponderEvent} from 'react-native'
import {
GestureResponderEvent,
Pressable,
StyleProp,
ViewStyle,
} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native'
@@ -35,11 +40,6 @@ type BaseLinkProps = Pick<
> & {
testID?: string
/**
* Label for a11y. Defaults to the href.
*/
label?: string
/**
* The React Navigation `StackAction` to perform when the link is pressed.
*/
@@ -192,7 +192,7 @@ export function useLink({
}
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
Omit<ButtonProps, 'onPress' | 'disabled'>
/**
* A interactive element that renders as a `<a>` tag on the web. On mobile it
@@ -219,7 +219,6 @@ export function Link({
return (
<Button
label={href}
{...rest}
style={[a.justify_start, flatten(rest.style)]}
role="link"
@@ -244,7 +243,8 @@ export function Link({
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
>
> &
Pick<ButtonProps, 'label'>
export function InlineLinkText({
children,
@@ -286,7 +286,7 @@ export function InlineLinkText({
<Text
selectable={selectable}
accessibilityHint=""
accessibilityLabel={label || href}
accessibilityLabel={label}
{...rest}
style={[
{color: t.palette.primary_500},
@@ -323,3 +323,45 @@ export function InlineLinkText({
</Text>
)
}
/**
* A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles
* in Link are not desired.
* @param displayText
* @param style
* @param children
* @param rest
* @constructor
*/
export function BaseLink({
displayText,
onPress: onPressOuter,
style,
children,
...rest
}: {
style?: StyleProp<ViewStyle>
children: React.ReactNode
to: string
action: 'push' | 'replace' | 'navigate'
onPress?: () => false | void
shareOnLongPress?: boolean
label: string
displayText?: string
}) {
const {onPress, ...btnProps} = useLink({
displayText: displayText ?? rest.to,
...rest,
})
return (
<Pressable
style={style}
onPress={e => {
onPressOuter?.()
onPress(e)
}}
{...btnProps}>
{children}
</Pressable>
)
}
+129
View File
@@ -0,0 +1,129 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
import {precacheList} from 'state/queries/feed'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {
Avatar,
Description,
Header,
Outer,
SaveButton,
} from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography'
/*
* This component is based on `FeedCard` and is tightly coupled with that
* component. Please refer to `FeedCard` for more context.
*/
export {
Avatar,
AvatarPlaceholder,
Description,
Header,
Outer,
SaveButton,
TitleAndBylinePlaceholder,
} from '#/components/FeedCard'
const CURATELIST = 'app.bsky.graph.defs#curatelist'
const MODLIST = 'app.bsky.graph.defs#modlist'
type Props = {
view: AppBskyGraphDefs.ListView
showPinButton?: boolean
}
export function Default(props: Props) {
const {view, showPinButton} = props
return (
<Link {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline
title={view.name}
creator={view.creator}
purpose={view.purpose}
/>
{showPinButton && view.purpose === CURATELIST && (
<SaveButton view={view} pin />
)}
</Header>
<Description description={view.description} />
</Outer>
</Link>
)
}
export function Link({
view,
children,
...props
}: Props & Omit<LinkProps, 'to' | 'label'>) {
const queryClient = useQueryClient()
const href = React.useMemo(() => {
return createProfileListHref({list: view})
}, [view])
React.useEffect(() => {
precacheList(queryClient, view)
}, [view, queryClient])
return (
<InternalLink label={view.name} to={href} {...props}>
{children}
</InternalLink>
)
}
export function TitleAndByline({
title,
creator,
purpose = CURATELIST,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
purpose?: AppBskyGraphDefs.ListView['purpose']
}) {
const t = useTheme()
return (
<View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
{title}
</Text>
{creator && (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{purpose === MODLIST ? (
<Trans>
Moderation list by {sanitizeHandle(creator.handle, '@')}
</Trans>
) : (
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
)}
</Text>
)}
</View>
)
}
export function createProfileListHref({
list,
}: {
list: AppBskyGraphDefs.ListView
}) {
const urip = new AtUri(list.uri)
const handleOrDid = list.creator.handle || list.creator.did
return `/profile/${handleOrDid}/lists/${urip.rkey}`
}
+1 -1
View File
@@ -189,7 +189,7 @@ let ListMaybePlaceholder = ({
return (
<Error
title={errorTitle ?? _(msg`Oops!`)}
message={errorMessage ?? _(`Something went wrong!`)}
message={errorMessage ?? _(msg`Something went wrong!`)}
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
+54 -25
View File
@@ -6,10 +6,11 @@ import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {HITSLOP_10} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -28,16 +29,27 @@ export function NewskieDialog({
const {_} = useLingui()
const t = useTheme()
const moderationOpts = useModerationOpts()
const {currentAccount} = useSession()
const timeAgo = useGetTimeAgo()
const control = useDialogControl()
const isMe = profile.did === currentAccount?.did
const createdAt = profile.createdAt as string | undefined
const profileName = React.useMemo(() => {
const name = profile.displayName || profile.handle
if (isMe) {
return _(msg`You`)
}
if (!moderationOpts) return name
const moderation = moderateProfile(profile, moderationOpts)
return sanitizeDisplayName(name, moderation.ui('displayName'))
}, [moderationOpts, profile])
}, [_, isMe, moderationOpts, profile])
const [now] = React.useState(() => Date.now())
const timeAgo = useGetTimeAgo()
const createdAt = profile.createdAt as string | undefined
const daysOld = React.useMemo(() => {
if (!createdAt) return Infinity
return differenceInSeconds(now, new Date(createdAt)) / 86400
@@ -70,19 +82,31 @@ export function NewskieDialog({
<Dialog.ScrollableInner
label={_(msg`New user info dialog`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<View style={[a.gap_md]}>
<View style={[a.align_center]}>
<Newskie
width={64}
height={64}
fill="#FFC404"
style={{marginTop: -10}}
/>
<Text style={[a.font_bold, a.text_xl, {marginTop: -10}]}>
<Trans>Say hello!</Trans>
<View
style={[
{
height: 60,
width: 64,
},
]}>
<Newskie
width={64}
height={64}
fill="#FFC404"
style={[a.absolute, a.inset_0]}
/>
</View>
<Text style={[a.font_bold, a.text_xl]}>
{isMe ? (
<Trans>Welcome, friend!</Trans>
) : (
<Trans>Say hello!</Trans>
)}
</Text>
</View>
<Text style={[a.text_md, a.text_center, a.leading_tight]}>
<Text style={[a.text_md, a.text_center, a.leading_snug]}>
{profile.joinedViaStarterPack ? (
<Trans>
{profileName} joined Bluesky using a starter pack{' '}
@@ -116,18 +140,23 @@ export function NewskieDialog({
</View>
</StarterPackCard.Link>
) : null}
<Button
label={_(msg`Close`)}
variant="solid"
color="secondary"
size="small"
style={[a.mt_sm, isWeb && [a.self_center, {marginLeft: 'auto'}]]}
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
{isNative && (
<Button
label={_(msg`Close`)}
variant="solid"
color="secondary"
size="small"
style={[a.mt_sm]}
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
</View>
+169
View File
@@ -0,0 +1,169 @@
import React from 'react'
import {View} from 'react-native'
import {BSKY_LABELER_DID, ModerationCause} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {Button} from '#/components/Button'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
export type CommonProps = {
size?: 'sm' | 'lg'
}
export function Row({
children,
style,
size = 'sm',
}: {children: React.ReactNode | React.ReactNode[]} & CommonProps &
ViewStyleProp) {
const styles = React.useMemo(() => {
switch (size) {
case 'lg':
return [{gap: 5}]
case 'sm':
default:
return [{gap: 3}]
}
}, [size])
return (
<View style={[a.flex_row, a.flex_wrap, a.gap_xs, styles, style]}>
{children}
</View>
)
}
export type LabelProps = {
cause: ModerationCause
disableDetailsDialog?: boolean
noBg?: boolean
} & CommonProps
export function Label({
cause,
size = 'sm',
disableDetailsDialog,
noBg,
}: LabelProps) {
const t = useTheme()
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
const isLabeler = Boolean(desc.sourceType && desc.sourceDid)
const isBlueskyLabel =
desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID
const {outer, avi, text} = React.useMemo(() => {
switch (size) {
case 'lg': {
return {
outer: [
t.atoms.bg_contrast_25,
{
gap: 5,
paddingHorizontal: 5,
paddingVertical: 5,
},
],
avi: 16,
text: [a.text_sm],
}
}
case 'sm':
default: {
return {
outer: [
!noBg && t.atoms.bg_contrast_25,
{
gap: 3,
paddingHorizontal: 3,
paddingVertical: 3,
},
],
avi: 12,
text: [a.text_xs],
}
}
}
}, [t, size, noBg])
return (
<>
<Button
disabled={disableDetailsDialog}
label={desc.name}
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.rounded_full,
outer,
(hovered || pressed) && t.atoms.bg_contrast_50,
]}>
{isBlueskyLabel || !isLabeler ? (
<desc.icon
width={avi}
fill={t.atoms.text_contrast_medium.color}
/>
) : (
<UserAvatar avatar={desc.sourceAvi} size={avi} />
)}
<Text
style={[
text,
a.font_semibold,
a.leading_tight,
t.atoms.text_contrast_medium,
{paddingRight: 3},
]}>
{desc.name}
</Text>
</View>
)}
</Button>
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)}
</>
)
}
export function FollowsYou({size = 'sm'}: CommonProps) {
const t = useTheme()
const variantStyles = React.useMemo(() => {
switch (size) {
case 'sm':
case 'lg':
default:
return [
{
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
},
]
}
}, [size])
return (
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_25]}>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>Follows You</Trans>
</Text>
</View>
)
}
+370 -69
View File
@@ -1,20 +1,33 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {GestureResponderEvent, View} from 'react-native'
import {
AppBskyActorDefs,
moderateProfile,
ModerationOpts,
RichText as RichTextApi,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name'
import {LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {sanitizeHandle} from 'lib/strings/handles'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {useSession} from 'state/session'
import {FollowButton} from 'view/com/profile/FollowButton'
import * as Toast from '#/view/com/util/Toast'
import {ProfileCardPills} from 'view/com/profile/ProfileCard'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Link} from '#/components/Link'
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
export function Default({
profile: profileUnshadowed,
profile,
moderationOpts,
logContext = 'ProfileCard',
}: {
@@ -22,70 +35,358 @@ export function Default({
moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) {
const t = useTheme()
const {currentAccount, hasSession} = useSession()
const profile = useProfileShadow(profileUnshadowed)
const name = createSanitizedDisplayName(profile)
const handle = `@${sanitizeHandle(profile.handle)}`
const moderation = moderateProfile(profile, moderationOpts)
return (
<Wrapper did={profile.did}>
<View style={[a.flex_row, a.gap_sm]}>
<UserAvatar
size={42}
avatar={profile.avatar}
type={
profile.associated?.labeler
? 'labeler'
: profile.associated?.feedgens
? 'algo'
: 'user'
}
moderation={moderation.ui('avatar')}
/>
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{name}
</Text>
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{handle}
</Text>
</View>
{hasSession && profile.did !== currentAccount?.did && (
<View style={[a.justify_center, {marginLeft: 'auto'}]}>
<FollowButton profile={profile} logContext={logContext} />
</View>
)}
</View>
<View style={[a.mb_xs]}>
<ProfileCardPills
followedBy={Boolean(profile.viewer?.followedBy)}
moderation={moderation}
/>
</View>
{profile.description && (
<Text numberOfLines={3} style={[a.leading_snug]}>
{profile.description}
</Text>
)}
</Wrapper>
)
}
function Wrapper({did, children}: {did: string; children: React.ReactNode}) {
return (
<Link
to={{
screen: 'Profile',
params: {name: did},
}}>
<View style={[a.flex_1, a.gap_xs]}>{children}</View>
<Link profile={profile}>
<Card
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
/>
</Link>
)
}
export function Card({
profile,
moderationOpts,
logContext = 'ProfileCard',
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) {
const moderation = moderateProfile(profile, moderationOpts)
return (
<Outer>
<Header>
<Avatar profile={profile} moderationOpts={moderationOpts} />
<NameAndHandle profile={profile} moderationOpts={moderationOpts} />
<FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext={logContext}
/>
</Header>
<ProfileCardPills
followedBy={Boolean(profile.viewer?.followedBy)}
moderation={moderation}
/>
<Description profile={profile} />
</Outer>
)
}
export function Outer({
children,
}: {
children: React.ReactElement | React.ReactElement[]
}) {
return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
}
export function Header({
children,
}: {
children: React.ReactElement | React.ReactElement[]
}) {
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
}
export function Link({
profile,
children,
style,
...rest
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
} & Omit<LinkProps, 'to' | 'label'>) {
const {_} = useLingui()
return (
<InternalLink
label={_(
msg`View ${
profile.displayName || sanitizeHandle(profile.handle)
}'s profile`,
)}
to={{
screen: 'Profile',
params: {name: profile.did},
}}
style={[a.flex_col, style]}
{...rest}>
{children}
</InternalLink>
)
}
export function Avatar({
profile,
moderationOpts,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
}) {
const moderation = moderateProfile(profile, moderationOpts)
return (
<UserAvatar
size={42}
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
moderation={moderation.ui('avatar')}
/>
)
}
export function AvatarPlaceholder() {
const t = useTheme()
return (
<View
style={[
a.rounded_full,
t.atoms.bg_contrast_50,
{
width: 42,
height: 42,
},
]}
/>
)
}
export function NameAndHandle({
profile,
moderationOpts,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const handle = sanitizeHandle(profile.handle, '@')
return (
<View style={[a.flex_1]}>
<Text
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{name}
</Text>
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{handle}
</Text>
</View>
)
}
export function NameAndHandlePlaceholder() {
const t = useTheme()
return (
<View style={[a.flex_1, a.gap_xs]}>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
width: '60%',
height: 14,
},
]}
/>
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_50,
{
width: '40%',
height: 10,
},
]}
/>
</View>
)
}
export function Description({
profile: profileUnshadowed,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
}) {
const profile = useProfileShadow(profileUnshadowed)
const {description} = profile
const rt = React.useMemo(() => {
if (!description) return
const rt = new RichTextApi({text: description || ''})
rt.detectFacetsWithoutResolution()
return rt
}, [description])
if (!rt) return null
if (
profile.viewer &&
(profile.viewer.blockedBy ||
profile.viewer.blocking ||
profile.viewer.blockingByList)
)
return null
return (
<View style={[a.pt_xs]}>
<RichText
value={rt}
style={[a.leading_snug]}
numberOfLines={3}
disableLinks
/>
</View>
)
}
export function DescriptionPlaceholder() {
const t = useTheme()
return (
<View style={[a.gap_xs]}>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
{height: 12, width: 100},
]}
/>
</View>
)
}
export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
const {currentAccount, hasSession} = useSession()
const isMe = props.profile.did === currentAccount?.did
return hasSession && !isMe ? <FollowButtonInner {...props} /> : null
}
export function FollowButtonInner({
profile: profileUnshadowed,
moderationOpts,
logContext,
...rest
}: FollowButtonProps) {
const {_} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const moderation = moderateProfile(profile, moderationOpts)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
logContext,
)
const isRound = Boolean(rest.shape && rest.shape === 'round')
const onPressFollow = async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
try {
await queueFollow()
Toast.show(
_(
msg`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)}`,
),
)
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
}
}
}
const onPressUnfollow = async (e: GestureResponderEvent) => {
e.preventDefault()
e.stopPropagation()
try {
await queueUnfollow()
Toast.show(
_(
msg`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)}`,
),
)
} catch (err: any) {
if (err?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
}
}
}
const unfollowLabel = _(
msg({
message: 'Following',
comment: 'User is following this account, click to unfollow',
}),
)
const followLabel = _(
msg({
message: 'Follow',
comment: 'User is not following this account, click to follow',
}),
)
if (!profile.viewer) return null
if (
profile.viewer.blockedBy ||
profile.viewer.blocking ||
profile.viewer.blockingByList
)
return null
return (
<View>
{profile.viewer.following ? (
<Button
label={unfollowLabel}
size="small"
variant="solid"
color="secondary"
{...rest}
onPress={onPressUnfollow}>
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
</Button>
) : (
<Button
label={followLabel}
size="small"
variant="solid"
color="primary"
{...rest}
onPress={onPressFollow}>
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
</Button>
)}
</View>
)
}
@@ -29,10 +29,10 @@ import {
} from '#/components/KnownFollowers'
import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Pills from '#/components/Pills'
import {Portal} from '#/components/Portal'
import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {ProfileLabel} from '../moderation/ProfileHeaderAlerts'
import {ProfileHoverCardProps} from './types'
const floatingMiddlewares = [
@@ -462,7 +462,8 @@ function Inner({
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
<View style={[a.pb_sm, a.flex_1]}>
<Text style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold]}>
<Text
style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold, a.self_start]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
@@ -476,8 +477,9 @@ function Inner({
{isBlockedUser && (
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{moderation.ui('profileView').alerts.map(cause => (
<ProfileLabel
<Pills.Label
key={getModerationCauseKey(cause)}
size="lg"
cause={cause}
disableDetailsDialog
/>
+61
View File
@@ -0,0 +1,61 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
useProgressGuide,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import {ProgressGuideTask} from './Task'
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
const t = useTheme()
const {_} = useLingui()
const guide = useProgressGuide('like-10-and-follow-7')
const {endProgressGuide} = useProgressGuideControls()
if (guide) {
return (
<View style={[a.flex_col, a.gap_md, style]}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<Text
style={[
t.atoms.text_contrast_medium,
a.font_semibold,
a.text_sm,
{textTransform: 'uppercase'},
]}>
<Trans>Getting started</Trans>
</Text>
<Button
variant="ghost"
size="tiny"
color="secondary"
shape="round"
label={_(msg`Dismiss getting started guide`)}
onPress={endProgressGuide}>
<ButtonIcon icon={Times} size="sm" />
</Button>
</View>
<ProgressGuideTask
current={guide.numLikes + 1}
total={10 + 1}
title={_(msg`Like 10 posts`)}
subtitle={_(msg`Teach our algorithm what you like`)}
/>
<ProgressGuideTask
current={guide.numFollows + 1}
total={7 + 1}
title={_(msg`Follow 7 accounts`)}
subtitle={_(msg`Bluesky is better with friends!`)}
/>
</View>
)
}
return null
}
+50
View File
@@ -0,0 +1,50 @@
import React from 'react'
import {View} from 'react-native'
import * as Progress from 'react-native-progress'
import {atoms as a, useTheme} from '#/alf'
import {AnimatedCheck} from '../anim/AnimatedCheck'
import {Text} from '../Typography'
export function ProgressGuideTask({
current,
total,
title,
subtitle,
}: {
current: number
total: number
title: string
subtitle?: string
}) {
const t = useTheme()
return (
<View style={[a.flex_row, a.gap_sm, !subtitle && a.align_center]}>
{current === total ? (
<AnimatedCheck playOnMount fill={t.palette.primary_500} width={20} />
) : (
<Progress.Circle
progress={current / total}
color={t.palette.primary_400}
size={20}
thickness={3}
borderWidth={0}
unfilledColor={t.palette.contrast_50}
/>
)}
<View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}>
<Text style={[a.text_sm, a.font_semibold, a.leading_tight]}>
{title}
</Text>
{subtitle && (
<Text
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}>
{subtitle}
</Text>
)}
</View>
</View>
)
}
+169
View File
@@ -0,0 +1,169 @@
import React, {useImperativeHandle} from 'react'
import {Pressable, useWindowDimensions, View} from 'react-native'
import Animated, {
Easing,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Portal} from '#/components/Portal'
import {AnimatedCheck, AnimatedCheckRef} from '../anim/AnimatedCheck'
import {Text} from '../Typography'
export interface ProgressGuideToastRef {
open(): void
close(): void
}
export interface ProgressGuideToastProps {
title: string
subtitle?: string
visibleDuration?: number // default 5s
}
export const ProgressGuideToast = React.forwardRef<
ProgressGuideToastRef,
ProgressGuideToastProps
>(function ProgressGuideToast({title, subtitle, visibleDuration}, ref) {
const t = useTheme()
const {_} = useLingui()
const insets = useSafeAreaInsets()
const [isOpen, setIsOpen] = React.useState(false)
const translateY = useSharedValue(0)
const opacity = useSharedValue(0)
const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null)
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>()
const winDim = useWindowDimensions()
/**
* Methods
*/
const close = React.useCallback(() => {
// clear the timeout, in case this was called imperatively
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = undefined
}
// animate the opacity then set isOpen to false when done
const setIsntOpen = () => setIsOpen(false)
opacity.value = withTiming(
0,
{
duration: 400,
easing: Easing.out(Easing.cubic),
},
() => runOnJS(setIsntOpen)(),
)
}, [setIsOpen, opacity])
const open = React.useCallback(() => {
// set isOpen=true to render
setIsOpen(true)
// animate the vertical translation, the opacity, and the checkmark
const playCheckmark = () => animatedCheckRef.current?.play()
opacity.value = 0
opacity.value = withTiming(
1,
{
duration: 100,
easing: Easing.out(Easing.cubic),
},
() => runOnJS(playCheckmark)(),
)
translateY.value = 0
translateY.value = withTiming(insets.top + 10, {
duration: 500,
easing: Easing.out(Easing.cubic),
})
// start the countdown timer to autoclose
timeoutRef.current = setTimeout(close, visibleDuration || 5e3)
}, [setIsOpen, translateY, opacity, insets, close, visibleDuration])
useImperativeHandle(
ref,
() => ({
open,
close,
}),
[open, close],
)
const containerStyle = React.useMemo(() => {
let left = 10
let right = 10
if (isWeb && winDim.width > 400) {
left = right = (winDim.width - 380) / 2
}
return {
position: isWeb ? 'fixed' : 'absolute',
top: 0,
left,
right,
}
}, [winDim.width])
const animatedStyle = useAnimatedStyle(() => ({
transform: [{translateY: translateY.value}],
opacity: opacity.value,
}))
return (
isOpen && (
<Portal>
<Animated.View
style={[
// @ts-ignore position: fixed is web only
containerStyle,
animatedStyle,
]}>
<Pressable
style={[
t.atoms.bg,
a.flex_row,
a.align_center,
a.gap_md,
a.border,
t.atoms.border_contrast_high,
a.rounded_md,
a.px_lg,
a.py_md,
a.shadow_sm,
{
shadowRadius: 8,
shadowOpacity: 0.1,
shadowOffset: {width: 0, height: 2},
elevation: 8,
},
]}
onPress={close}
accessibilityLabel={_(msg`Tap to dismiss`)}
accessibilityHint="">
<AnimatedCheck
fill={t.palette.primary_500}
ref={animatedCheckRef}
/>
<View>
<Text style={[a.text_md, a.font_semibold]}>{title}</Text>
{subtitle && (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{subtitle}
</Text>
)}
</View>
</Pressable>
</Animated.View>
</Portal>
)
)
})
+26 -9
View File
@@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {useGate} from '#/lib/statsig/statsig'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
@@ -36,6 +37,7 @@ export function SubmitView({
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
const gate = useGate()
const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>([
@@ -60,15 +62,29 @@ export function SubmitView({
reason: details,
}
const results = await Promise.all(
selectedServices.map(did =>
agent
.withProxy('atproto_labeler', did)
.createModerationReport(report)
.then(
_ => true,
_ => false,
),
),
selectedServices.map(did => {
if (gate('session_withproxy_fix')) {
return agent
.createModerationReport(report, {
encoding: 'application/json',
headers: {
'atproto-proxy': `${did}#atproto_labeler`,
},
})
.then(
_ => true,
_ => false,
)
} else {
return agent
.withProxy('atproto_labeler', did)
.createModerationReport(report)
.then(
_ => true,
_ => false,
)
}
}),
)
setSubmitting(false)
@@ -92,6 +108,7 @@ export function SubmitView({
onSubmitComplete,
setError,
agent,
gate,
])
return (
+14 -12
View File
@@ -17,6 +17,19 @@ import {Text, TextProps} from '#/components/Typography'
const WORD_WRAP = {wordWrap: 1}
export type RichTextProps = TextStyleProp &
Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
disableLinks?: boolean
enableTags?: boolean
authorHandle?: string
onLinkPress?: LinkProps['onPress']
interactiveStyle?: TextStyle
emojiMultiplier?: number
}
export function RichText({
testID,
value,
@@ -29,18 +42,7 @@ export function RichText({
onLinkPress,
interactiveStyle,
emojiMultiplier = 1.85,
}: TextStyleProp &
Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
disableLinks?: boolean
enableTags?: boolean
authorHandle?: string
onLinkPress?: LinkProps['onPress']
interactiveStyle?: TextStyle
emojiMultiplier?: number
}) {
}: RichTextProps) {
const richText = React.useMemo(
() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
@@ -45,7 +45,7 @@ export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
(isWeb || index !== 0) && a.border_t,
t.atoms.border_contrast_low,
]}>
<FeedCard.Default type="feed" view={item} />
<FeedCard.Default view={item} />
</View>
)
}
@@ -0,0 +1,51 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {isNative} from 'platform/detection'
import {Feed} from 'view/com/posts/Feed'
import {EmptyState} from 'view/com/util/EmptyState'
import {ListRef} from 'view/com/util/List'
import {SectionRef} from '#/screens/Profile/Sections/types'
interface ProfilesListProps {
listUri: string
headerHeight: number
scrollElRef: ListRef
}
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
const feed: FeedDescriptor = `list|${listUri}|as_following`
const {_} = useLingui()
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
animated: isNative,
offset: -headerHeight,
})
}, [scrollElRef, headerHeight])
React.useImperativeHandle(ref, () => ({
scrollToTop: onScrollToTop,
}))
const renderPostsEmpty = useCallback(() => {
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
}, [_])
return (
<View>
<Feed
feed={feed}
pollInterval={60e3}
scrollElRef={scrollElRef}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
</View>
)
},
)
@@ -9,11 +9,14 @@ import {
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
import {isNative, isWeb} from 'platform/detection'
import {useListMembersQuery} from 'state/queries/list-members'
import {useSession} from 'state/session'
import {List, ListRef} from 'view/com/util/List'
import {SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
@@ -32,21 +35,21 @@ interface ProfilesListProps {
export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
function ProfilesListImpl(
{listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef},
{listUri, moderationOpts, headerHeight, scrollElRef},
ref,
) {
const t = useTheme()
const [initialHeaderHeight] = React.useState(headerHeight)
const bottomBarOffset = useBottomBarOffset(20)
const {currentAccount} = useSession()
const {data, refetch, isError} = useListMembersQuery(listUri, 50)
const [isPTRing, setIsPTRing] = React.useState(false)
const {data, refetch} = listMembersQuery
// The server returns these sorted by descending creation date, so we want to invert
const profiles = data?.pages
.flatMap(p => p.items.map(i => i.subject))
.filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler)
.reverse()
const isOwn = new AtUri(listUri).host === currentAccount?.did
@@ -94,7 +97,19 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
)
}
if (listMembersQuery)
if (!data) {
return (
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
<ListMaybePlaceholder
isLoading={true}
isError={isError}
onRetry={refetch}
/>
</View>
)
}
if (data)
return (
<List
data={getSortedProfiles()}
@@ -232,11 +232,13 @@ function Empty() {
t.atoms.text_contrast_medium,
{color: 'white'},
]}>
You haven't created a starter pack yet!
<Trans>You haven't created a starter pack yet!</Trans>
</Text>
<Text style={[a.text_md, {color: 'white'}]}>
Starter packs let you easily share your favorite feeds and people with
your friends.
<Trans>
Starter packs let you easily share your favorite feeds and people
with your friends.
</Trans>
</Text>
</View>
<View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}>
+1 -1
View File
@@ -110,7 +110,7 @@ export function QrCodeInner({link}: {link: string}) {
innerEyesOptions={{borderRadius: 3}}
logo={{
href: require('../../../assets/logo.png'),
scale: 1.2,
scale: 0.95,
padding: 2,
hidePieces: true,
}}
+6 -9
View File
@@ -1,16 +1,14 @@
import React from 'react'
import {View} from 'react-native'
import ViewShot from 'react-native-view-shot'
import * as FS from 'expo-file-system'
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
import {createAssetAsync} from 'expo-media-library'
import * as Sharing from 'expo-sharing'
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure'
import {logger} from '#/logger'
import {saveImageToMediaLibrary} from 'lib/media/manip'
import {logEvent} from 'lib/statsig/statsig'
import {isNative, isWeb} from 'platform/detection'
import * as Toast from '#/view/com/util/Toast'
@@ -65,15 +63,14 @@ export function QrCodeDialog({
return
}
const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
// Incase of a FS failure, don't crash the app
try {
await FS.copyAsync({from: uri, to: filename})
await saveImageToMediaLibrary({uri: filename})
await FS.deleteAsync(filename)
await createAssetAsync(`file://${uri}`)
} catch (e: unknown) {
Toast.show(_(msg`An error occurred while saving the QR code!`))
Toast.show(
_(msg`An error occurred while saving the QR code!`),
'xmark',
)
logger.error('Failed to save QR code', {error: e})
return
}
+7 -19
View File
@@ -1,12 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import * as FS from 'expo-file-system'
import {Image} from 'expo-image'
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
import {AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {nanoid} from 'nanoid/non-secure'
import {logger} from '#/logger'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -68,27 +66,17 @@ function ShareDialogInner({
if (!res) {
Toast.show(
_(msg`You must grant access to your photo library to save the image.`),
'xmark',
)
return
}
const cachePath = await Image.getCachePathAsync(imageUrl)
const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
if (!cachePath) {
Toast.show(_(msg`An error occurred while saving the image.`))
return
}
try {
await FS.copyAsync({from: cachePath, to: filename})
await saveImageToMediaLibrary({uri: filename})
await FS.deleteAsync(filename)
await saveImageToMediaLibrary({uri: imageUrl})
Toast.show(_(msg`Image saved to your camera roll!`))
control.close()
} catch (e: unknown) {
Toast.show(_(msg`An error occurred while saving the QR code!`))
Toast.show(_(msg`An error occurred while saving the QR code!`), 'xmark')
logger.error('Failed to save QR code', {error: e})
return
}
@@ -133,18 +121,18 @@ function ShareDialogInner({
isWeb && [a.gap_sm, a.flex_row_reverse, {marginLeft: 'auto'}],
]}>
<Button
label="Share link"
label={isWeb ? _(msg`Copy link`) : _(msg`Share link`)}
variant="solid"
color="secondary"
size="small"
style={[isWeb && a.self_center]}
onPress={onShareLink}>
<ButtonText>
{isWeb ? <Trans>Copy Link</Trans> : <Trans>Share Link</Trans>}
{isWeb ? <Trans>Copy Link</Trans> : <Trans>Share link</Trans>}
</ButtonText>
</Button>
<Button
label="Create QR code"
label={_(msg`Share QR code`)}
variant="solid"
color="secondary"
size="small"
@@ -155,7 +143,7 @@ function ShareDialogInner({
})
}}>
<ButtonText>
<Trans>Create QR code</Trans>
<Trans>Share QR code</Trans>
</ButtonText>
</Button>
{isNative && (
+50 -10
View File
@@ -1,15 +1,20 @@
import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {precacheResolvedUri} from 'state/queries/resolve-uri'
import {precacheStarterPack} from 'state/queries/starter-packs'
import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf'
import {StarterPack} from '#/components/icons/StarterPack'
import {Link as InternalLink, LinkProps} from '#/components/Link'
import {BaseLink} from '#/components/Link'
import {Text} from '#/components/Typography'
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
@@ -88,10 +93,13 @@ export function Card({
export function Link({
starterPack,
children,
...rest
}: {
starterPack: StarterPackViewBasic
} & Omit<LinkProps, 'to'>) {
onPress?: () => void
children: React.ReactNode
}) {
const {_} = useLingui()
const queryClient = useQueryClient()
const {record} = starterPack
const {rkey, handleOrDid} = React.useMemo(() => {
const rkey = new AtUri(starterPack.uri).rkey
@@ -104,14 +112,46 @@ export function Link({
}
return (
<InternalLink
label={record.name}
{...rest}
to={{
screen: 'StarterPack',
params: {name: handleOrDid, rkey},
<BaseLink
action="push"
to={`/starter-pack/${handleOrDid}/${rkey}`}
label={_(msg`Navigate to ${record.name}`)}
onPress={() => {
precacheResolvedUri(
queryClient,
starterPack.creator.handle,
starterPack.creator.did,
)
precacheStarterPack(queryClient, starterPack)
}}>
{children}
</InternalLink>
</BaseLink>
)
}
export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) {
const t = useTheme()
const imageUri = getStarterPackOgCard(starterPack)
return (
<View
style={[
a.mt_xs,
a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
]}>
<Link starterPack={starterPack}>
<Image
source={imageUri}
style={[a.w_full, {aspectRatio: 1.91}]}
accessibilityIgnoresInvertColors={true}
/>
<View style={[a.px_sm, a.py_md]}>
<Card starterPack={starterPack} />
</View>
</Link>
</View>
)
}
@@ -58,6 +58,7 @@ export function WizardEditListDialog({
state.currentStep === 'Profiles' ? (
<WizardProfileCard
profile={item}
btnType="remove"
state={state}
dispatch={dispatch}
moderationOpts={moderationOpts}
@@ -65,6 +66,7 @@ export function WizardEditListDialog({
) : (
<WizardFeedCard
generator={item}
btnType="remove"
state={state}
dispatch={dispatch}
moderationOpts={moderationOpts}
@@ -9,7 +9,7 @@ import {
ModerationUI,
} from '@atproto/api'
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DISCOVER_FEED_URI} from 'lib/constants'
@@ -19,12 +19,14 @@ import {useSession} from 'state/session'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Toggle from '#/components/forms/Toggle'
import {Checkbox} from '#/components/forms/Toggle'
import {Text} from '#/components/Typography'
function WizardListCard({
type,
btnType,
displayName,
subtitle,
onPress,
@@ -34,6 +36,7 @@ function WizardListCard({
moderationUi,
}: {
type: 'user' | 'algo'
btnType: 'checkbox' | 'remove'
profile?: AppBskyActorDefs.ProfileViewBasic
feed?: AppBskyFeedDefs.GeneratorView
displayName: string
@@ -56,7 +59,7 @@ function WizardListCard({
: _(msg`Add ${displayName} to starter pack`)
}
value={included}
disabled={disabled}
disabled={btnType === 'remove' || disabled}
onChange={onPress}
style={[
a.flex_row,
@@ -75,7 +78,13 @@ function WizardListCard({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[a.flex_1, a.font_bold, a.text_md, a.leading_tight]}
style={[
a.flex_1,
a.font_bold,
a.text_md,
a.leading_tight,
a.self_start,
]}
numberOfLines={1}>
{displayName}
</Text>
@@ -85,17 +94,33 @@ function WizardListCard({
{subtitle}
</Text>
</View>
<Checkbox />
{btnType === 'checkbox' ? (
<Checkbox />
) : !disabled ? (
<Button
label={_(msg`Remove`)}
variant="solid"
color="secondary"
size="xsmall"
style={[a.self_center, {marginLeft: 'auto'}]}
onPress={onPress}>
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
</Button>
) : null}
</Toggle.Item>
)
}
export function WizardProfileCard({
btnType,
state,
dispatch,
profile,
moderationOpts,
}: {
btnType: 'checkbox' | 'remove'
state: WizardState
dispatch: (action: WizardAction) => void
profile: AppBskyActorDefs.ProfileViewBasic
@@ -127,6 +152,7 @@ export function WizardProfileCard({
return (
<WizardListCard
type="user"
btnType={btnType}
displayName={displayName}
subtitle={`@${sanitizeHandle(profile.handle)}`}
onPress={onPress}
@@ -139,11 +165,13 @@ export function WizardProfileCard({
}
export function WizardFeedCard({
btnType,
generator,
state,
dispatch,
moderationOpts,
}: {
btnType: 'checkbox' | 'remove'
generator: GeneratorView
state: WizardState
dispatch: (action: WizardAction) => void
@@ -170,6 +198,7 @@ export function WizardFeedCard({
return (
<WizardListCard
type="algo"
btnType={btnType}
displayName={sanitizeDisplayName(generator.displayName)}
subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`}
onPress={onPress}
@@ -17,7 +17,6 @@ import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import {
ThreadgateSetting,
@@ -33,7 +32,9 @@ import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/componen
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {Text} from '#/components/Typography'
import {TextLink} from '../util/Link'
import {TextLink} from '../view/com/util/Link'
import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps {
post: AppBskyFeedDefs.PostView
@@ -41,15 +42,19 @@ interface WhoCanReplyProps {
style?: StyleProp<ViewStyle>
}
export function WhoCanReplyInline({
post,
isThreadAuthor,
style,
}: WhoCanReplyProps) {
export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const {_} = useLingui()
const t = useTheme()
const infoDialogControl = useDialogControl()
const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
const editDialogControl = useDialogControl()
const agent = useAgent()
const queryClient = useQueryClient()
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post],
)
const isRootPost = !('reply' in post.record)
if (!isRootPost) {
return null
@@ -66,6 +71,56 @@ export function WhoCanReplyInline({
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
const onPressEdit = () => {
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
if (isThreadAuthor) {
editDialogControl.open()
} else {
infoDialogControl.open()
}
}
const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
return
}
try {
if (newSettings.length) {
await createThreadgate(agent, post.uri, newSettings)
} else {
await agent.api.com.atproto.repo.deleteRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: new AtUri(post.uri).rkey,
})
}
await whenAppViewReady(agent, post.uri, res => {
const thread = res.data.thread
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
const fetchedSettings = threadgateViewToSettings(
thread.post.threadgate,
)
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
}
return false
})
Toast.show(_(msg`Thread settings updated`))
queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT],
})
} catch (err) {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
logger.error('Failed to edit threadgate', {message: err})
}
}
return (
<>
<Button
@@ -90,73 +145,24 @@ export function WhoCanReplyInline({
]}>
{description}
</Text>
{isThreadAuthor && (
<PencilLine width={12} fill={t.palette.primary_500} />
)}
</View>
)}
</Button>
<InfoDialog control={infoDialogControl} post={post} settings={settings} />
</>
)
}
export function WhoCanReplyBlock({
post,
isThreadAuthor,
style,
}: WhoCanReplyProps) {
const {_} = useLingui()
const t = useTheme()
const infoDialogControl = useDialogControl()
const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
if (!isRootPost) {
return null
}
if (!settings.length && !isThreadAuthor) {
return null
}
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
const description = isEverybody
? _(msg`Everybody can reply`)
: isNobody
? _(msg`Replies on this thread are disabled`)
: _(msg`Some people can reply`)
return (
<>
<Button
label={
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
}
onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
hitSlop={HITSLOP_10}>
{({hovered}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.py_sm,
a.pr_lg,
style,
]}>
<View style={[{paddingLeft: 25, paddingRight: 18}]}>
<Icon color={t.palette.contrast_300} settings={settings} />
</View>
<Text
style={[
a.text_sm,
a.leading_tight,
t.atoms.text_contrast_medium,
hovered && a.underline,
]}>
{description}
</Text>
</View>
)}
</Button>
<InfoDialog control={infoDialogControl} post={post} settings={settings} />
<WhoCanReplyDialog
control={infoDialogControl}
post={post}
settings={settings}
/>
{isThreadAuthor && (
<ThreadgateEditorDialog
control={editDialogControl}
threadgate={settings}
onConfirm={onEditConfirm}
/>
)}
</>
)
}
@@ -176,7 +182,7 @@ function Icon({
return <IconComponent fill={color} width={width} />
}
function InfoDialog({
function WhoCanReplyDialog({
control,
post,
settings,
@@ -188,12 +194,12 @@ function InfoDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<InfoDialogInner post={post} settings={settings} />
<WhoCanReplyDialogInner post={post} settings={settings} />
</Dialog.Outer>
)
}
function InfoDialogInner({
function WhoCanReplyDialogInner({
post,
settings,
}: {
@@ -315,64 +321,6 @@ function Separator({i, length}: {i: number; length: number}) {
return <>, </>
}
function useWhoCanReply(post: AppBskyFeedDefs.PostView) {
const agent = useAgent()
const queryClient = useQueryClient()
const {openModal} = useModalControls()
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post],
)
const isRootPost = !('reply' in post.record)
const onPressEdit = () => {
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
openModal({
name: 'threadgate',
settings,
async onConfirm(newSettings: ThreadgateSetting[]) {
try {
if (newSettings.length) {
await createThreadgate(agent, post.uri, newSettings)
} else {
await agent.api.com.atproto.repo.deleteRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: new AtUri(post.uri).rkey,
})
}
await whenAppViewReady(agent, post.uri, res => {
const thread = res.data.thread
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
const fetchedSettings = threadgateViewToSettings(
thread.post.threadgate,
)
return (
JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
)
}
return false
})
Toast.show('Thread settings updated')
queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT],
})
} catch (err) {
Toast.show(
'There was an issue. Please check your internet connection and try again.',
)
logger.error('Failed to edit threadgate', {message: err})
}
},
})
}
return {settings, isRootPost, onPressEdit}
}
async function whenAppViewReady(
agent: BskyAgent,
uri: string,
+92
View File
@@ -0,0 +1,92 @@
import React from 'react'
import Animated, {
Easing,
useAnimatedProps,
useSharedValue,
withDelay,
withTiming,
} from 'react-native-reanimated'
import Svg, {Circle, Path} from 'react-native-svg'
import {Props, useCommonSVGProps} from '#/components/icons/common'
const AnimatedPath = Animated.createAnimatedComponent(Path)
const AnimatedCircle = Animated.createAnimatedComponent(Circle)
const PATH = 'M14.1 27.2l7.1 7.2 16.7-16.8'
export interface AnimatedCheckRef {
play(cb?: () => void): void
}
export interface AnimatedCheckProps extends Props {
playOnMount?: boolean
}
export const AnimatedCheck = React.forwardRef<
AnimatedCheckRef,
AnimatedCheckProps
>(function AnimatedCheck({playOnMount, ...props}, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
const circleAnim = useSharedValue(0)
const checkAnim = useSharedValue(0)
const circleAnimatedProps = useAnimatedProps(() => ({
strokeDashoffset: 166 - circleAnim.value * 166,
}))
const checkAnimatedProps = useAnimatedProps(() => ({
strokeDashoffset: 48 - 48 * checkAnim.value,
}))
const play = React.useCallback(
(cb?: () => void) => {
circleAnim.value = 0
checkAnim.value = 0
circleAnim.value = withTiming(1, {duration: 500, easing: Easing.linear})
checkAnim.value = withDelay(
500,
withTiming(1, {duration: 300, easing: Easing.linear}, cb),
)
},
[circleAnim, checkAnim],
)
React.useImperativeHandle(ref, () => ({
play,
}))
React.useEffect(() => {
if (playOnMount) {
play()
}
}, [play, playOnMount])
return (
<Svg
fill="none"
{...rest}
viewBox="0 0 52 52"
width={size}
height={size}
style={style}>
<AnimatedCircle
animatedProps={circleAnimatedProps}
cx="26"
cy="26"
r="24"
fill="none"
stroke={fill}
strokeWidth={4}
strokeDasharray={166}
/>
<AnimatedPath
animatedProps={checkAnimatedProps}
stroke={fill}
d={PATH}
strokeWidth={4}
strokeDasharray={48}
/>
</Svg>
)
})
+1
View File
@@ -113,6 +113,7 @@ export function EmbedConsentDialog({
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
+3 -4
View File
@@ -357,12 +357,11 @@ function TargetToggle({children}: React.PropsWithChildren<{}>) {
a.px_sm,
gtMobile && a.px_md,
a.rounded_sm,
t.atoms.bg_contrast_50,
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
t.atoms.bg_contrast_25,
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50,
ctx.selected && [
{
backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
backgroundColor: t.palette.primary_50,
},
],
ctx.disabled && {
+217
View File
@@ -0,0 +1,217 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import isEqual from 'lodash.isequal'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Text} from '#/components/Typography'
interface ThreadgateEditorDialogProps {
control: Dialog.DialogControlProps
threadgate: ThreadgateSetting[]
onChange?: (v: ThreadgateSetting[]) => void
onConfirm?: (v: ThreadgateSetting[]) => void
}
export function ThreadgateEditorDialog({
control,
threadgate,
onChange,
onConfirm,
}: ThreadgateEditorDialogProps) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogContent
seedThreadgate={threadgate}
onChange={onChange}
onConfirm={onConfirm}
/>
</Dialog.Outer>
)
}
function DialogContent({
seedThreadgate,
onChange,
onConfirm,
}: {
seedThreadgate: ThreadgateSetting[]
onChange?: (v: ThreadgateSetting[]) => void
onConfirm?: (v: ThreadgateSetting[]) => void
}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [draft, setDraft] = React.useState(seedThreadgate)
const [prevSeedThreadgate, setPrevSeedThreadgate] =
React.useState(seedThreadgate)
if (seedThreadgate !== prevSeedThreadgate) {
// New data flowed from above (e.g. due to update coming through).
setPrevSeedThreadgate(seedThreadgate)
setDraft(seedThreadgate) // Reset draft.
}
function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
setDraft(nextThreadgate)
onChange?.(nextThreadgate)
}
const onPressEverybody = () => {
updateThreadgate([])
}
const onPressNobody = () => {
updateThreadgate([{type: 'nobody'}])
}
const onPressAudience = (setting: ThreadgateSetting) => {
// remove nobody
let newSelected: ThreadgateSetting[] = draft.filter(
v => v.type !== 'nobody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
newSelected.push(setting)
} else {
newSelected.splice(i, 1)
}
updateThreadgate(newSelected)
}
const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
return (
<Dialog.ScrollableInner
label={_(msg`Choose who can reply`)}
style={[{maxWidth: 500}, a.w_full]}>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Choose who can reply</Trans>
</Text>
<Text style={a.mt_xs}>
<Trans>Either choose "Everybody" or "Nobody"</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={draft.length === 0}
onPress={onPressEverybody}
style={{flex: 1}}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={!!draft.find(v => v.type === 'nobody')}
onPress={onPressNobody}
style={{flex: 1}}
/>
</View>
<Text style={a.mt_md}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={!!draft.find(v => v.type === 'mention')}
onPress={() => onPressAudience({type: 'mention'})}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={!!draft.find(v => v.type === 'following')}
onPress={() => onPressAudience({type: 'following'})}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!draft.find(v => v.type === 'list' && v.list === list.uri)
}
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
null}
</View>
</View>
<Button
label={doneLabel}
onPress={() => {
control.close()
onConfirm?.(draft)
}}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{doneLabel}</ButtonText>
</Button>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<Button
onPress={onPress}
label={label}
accessibilityHint="Select this option"
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}
@@ -42,6 +42,7 @@ export function BlockedByListDialog({
<React.Fragment key={block.source.list.uri}>
{i === 0 ? null : ', '}
<InlineLinkText
label={block.source.list.name}
to={listUriToHref(block.source.list.uri)}
style={[a.text_md, a.leading_snug]}>
{block.source.list.name}
+1 -1
View File
@@ -85,7 +85,7 @@ let ConvoMenu = ({
}
},
onError: () => {
Toast.show(_(msg`Could not mute chat`))
Toast.show(_(msg`Could not mute chat`), 'xmark')
},
})
+1 -1
View File
@@ -36,7 +36,7 @@ export function LeaveConvoPrompt({
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`))
Toast.show(_(msg`Could not leave chat`), 'xmark')
},
})
+2 -8
View File
@@ -72,8 +72,7 @@ let MessageItem = ({
lastInGroupRef.current = isLastInGroup
}
const pendingColor =
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
const pendingColor = t.palette.primary_200
const rt = useMemo(() => {
return new RichTextAPI({text: message.text, facets: message.facets})
@@ -110,12 +109,7 @@ let MessageItem = ({
}>
<RichText
value={rt}
style={[
a.text_md,
isFromSelf && {color: t.palette.white},
isPending &&
t.name !== 'light' && {color: t.palette.primary_300},
]}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
+1 -1
View File
@@ -54,7 +54,7 @@ export let MessageMenu = ({
)
Clipboard.setStringAsync(str)
Toast.show(_(msg`Copied to clipboard`))
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
}, [_, message.text, message.facets])
const onPressTranslateMessage = React.useCallback(() => {
+9 -2
View File
@@ -140,6 +140,7 @@ function HeaderReady({
userBlock?: ModerationCause
}
}) {
const {_} = useLingui()
const t = useTheme()
const convoState = useConvo()
const profile = useProfileShadow(profileUnshadowed)
@@ -156,6 +157,7 @@ function HeaderReady({
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
<Link
label={_(msg`View ${displayName}'s profile`)}
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<View style={[a.pt_2xs]}>
@@ -168,7 +170,12 @@ function HeaderReady({
</View>
<View style={a.flex_1}>
<Text
style={[a.text_md, a.font_bold, web(a.leading_normal)]}
style={[
a.text_md,
a.font_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}>
{displayName}
</Text>
@@ -214,7 +221,7 @@ function HeaderReady({
]}>
<PostAlerts
modui={moderation.ui('contentList')}
size="large"
size="lg"
style={[a.pt_xs]}
/>
</View>
+1 -1
View File
@@ -57,7 +57,7 @@ function DialogInner({
const [initialized, setInitialzed] = React.useState(false)
const {mutate: updateDeclaration} = useUpdateActorDeclaration({
onError: () => {
Toast.show(_(msg`Failed to update settings`))
Toast.show(_(msg`Failed to update settings`), 'xmark')
},
})
+3 -1
View File
@@ -2,6 +2,7 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import {FAB} from '#/view/com/util/fab/FAB'
@@ -31,7 +32,8 @@ export function NewChat({
logEvent('chat:open', {logContext: 'NewChatDialog'})
},
onError: error => {
Toast.show(error.message)
logger.error('Failed to create chat', {safeMessage: error})
Toast.show(_(msg`An issue occurred starting the chat`), 'xmark')
},
})
@@ -395,7 +395,7 @@ function ProfileCard({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
style={[t.atoms.text, a.font_bold, a.leading_tight]}
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
numberOfLines={1}>
{displayName}
</Text>
@@ -2,6 +2,7 @@ import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import * as Toast from '#/view/com/util/Toast'
@@ -43,7 +44,11 @@ function SendViaChatDialogInner({
logEvent('chat:open', {logContext: 'SendViaChatDialog'})
},
onError: error => {
Toast.show(error.message)
logger.error('Failed to share post to chat', {message: error})
Toast.show(
_(msg`An issue occurred while trying to open the chat`),
'xmark',
)
},
})
+3 -6
View File
@@ -101,16 +101,13 @@ export function useSharedInputStyles() {
]
const error: ViewStyle[] = [
{
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
backgroundColor: t.palette.negative_25,
borderColor: t.palette.negative_300,
},
]
const errorHover: ViewStyle[] = [
{
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
backgroundColor: t.palette.negative_25,
borderColor: t.palette.negative_500,
},
]
+27 -28
View File
@@ -1,6 +1,8 @@
import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {isNative} from '#/platform/detection'
import {HITSLOP_10} from 'lib/constants'
import {
atoms as a,
@@ -244,6 +246,7 @@ export function LabelText({
<Text
style={[
a.font_bold,
a.leading_tight,
{
userSelect: 'none',
color: disabled
@@ -251,7 +254,7 @@ export function LabelText({
: t.atoms.text_contrast_high.color,
},
native({
paddingTop: 3,
paddingTop: 2,
}),
flatten(style),
]}>
@@ -281,24 +284,20 @@ export function createSharedToggleStyles({
if (selected) {
base.push({
backgroundColor:
t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900,
backgroundColor: t.palette.primary_25,
borderColor: t.palette.primary_500,
})
if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
borderColor:
t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400,
backgroundColor: t.palette.primary_100,
borderColor: t.palette.primary_600,
})
}
} else {
if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
backgroundColor: t.palette.contrast_50,
borderColor: t.palette.contrast_500,
})
}
@@ -306,16 +305,13 @@ export function createSharedToggleStyles({
if (isInvalid) {
base.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
backgroundColor: t.palette.negative_25,
borderColor: t.palette.negative_300,
})
if (hovered) {
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
backgroundColor: t.palette.negative_25,
borderColor: t.palette.negative_600,
})
}
@@ -351,10 +347,10 @@ export function Checkbox() {
style={[
a.justify_center,
a.align_center,
a.border,
a.rounded_xs,
t.atoms.border_contrast_high,
{
borderWidth: 1,
height: 20,
width: 20,
},
@@ -382,34 +378,35 @@ export function Switch() {
<View
style={[
a.relative,
a.border,
a.rounded_full,
t.atoms.bg,
t.atoms.border_contrast_high,
{
borderWidth: 1,
height: 20,
width: 30,
width: 32,
padding: 2,
},
baseStyles,
hovered ? baseHoverStyles : {},
]}>
<View
<Animated.View
layout={LinearTransition.duration(100)}
style={[
a.absolute,
a.rounded_full,
{
height: 12,
width: 12,
top: 3,
left: 3,
backgroundColor: t.palette.contrast_400,
height: 14,
width: 14,
},
selected
? {
backgroundColor: t.palette.primary_500,
left: 13,
alignSelf: 'flex-end',
}
: {},
: {
backgroundColor: t.palette.contrast_400,
alignSelf: 'flex-start',
},
indicatorStyles,
]}
/>
@@ -435,10 +432,10 @@ export function Radio() {
style={[
a.justify_center,
a.align_center,
a.border,
a.rounded_full,
t.atoms.border_contrast_high,
{
borderWidth: 1,
height: 20,
width: 20,
},
@@ -463,3 +460,5 @@ export function Radio() {
</View>
)
}
export const Platform = isNative ? Switch : Checkbox
+2 -2
View File
@@ -32,7 +32,7 @@ export function useFollowMethods({
} catch (e: any) {
logger.error(`useFollowMethods: failed to follow`, {message: String(e)})
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`))
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
}
}
})
@@ -47,7 +47,7 @@ export function useFollowMethods({
message: String(e),
})
if (e?.name !== 'AbortError') {
Toast.show(_(msg`An issue occurred, please try again.`))
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
}
}
})
@@ -7,7 +7,7 @@ import {
import {isAndroid} from 'platform/detection'
import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
import {useSetActiveStarterPack} from 'state/shell/starter-pack'
import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army'
import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
export function useStarterPackEntry() {
const [ready, setReady] = React.useState(false)
@@ -39,14 +39,10 @@ export function useStarterPackEntry() {
uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer)
}
} else {
const res = await DevicePrefs.getStringValueAsync(
'starterPackUri',
true,
)
if (res) {
uri = httpStarterPackUriToAtUri(res)
DevicePrefs.setStringValueAsync('starterPackUri', null, true)
const starterPackUri = SharedPrefs.getString('starterPackUri')
if (starterPackUri) {
uri = httpStarterPackUriToAtUri(starterPackUri)
SharedPrefs.setValue('starterPackUri', null)
}
}
+4
View File
@@ -8,6 +8,10 @@ export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
})
export const ArrowRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z',
})
export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
})
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
})
+9
View File
@@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
})
export const Play_Filled_Corner2_Rounded = createSinglePathSVG({
path: 'M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z',
})
+3 -2
View File
@@ -4,7 +4,7 @@ import type {PathProps, SvgProps} from 'react-native-svg'
import {Defs, LinearGradient, Stop} from 'react-native-svg'
import {nanoid} from 'nanoid/non-secure'
import {tokens} from '#/alf'
import {tokens, useTheme} from '#/alf'
export type Props = {
fill?: PathProps['fill']
@@ -22,10 +22,11 @@ export const sizes = {
}
export function useCommonSVGProps(props: Props) {
const t = useTheme()
const {fill, size, gradient, ...rest} = props
const style = StyleSheet.flatten(rest.style)
const _size = Number(size ? sizes[size] : rest.width || sizes.md)
let _fill = fill || style?.color || tokens.color.blue_500
let _fill = fill || style?.color || t.palette.primary_500
let gradientDef = null
if (gradient && tokens.gradients[gradient]) {
+1 -3
View File
@@ -165,9 +165,7 @@ export function ContentHider({
}
const styles = StyleSheet.create({
outer: {
overflow: 'hidden',
},
outer: {},
cover: {
flexDirection: 'row',
alignItems: 'center',
@@ -174,7 +174,7 @@ export function LabelerLabelPreference({
disabled?: boolean
labelerDid?: string
}) {
const {i18n} = useLingui()
const {_, i18n} = useLingui()
const t = useTheme()
const {gtPhone} = useBreakpoints()
@@ -243,7 +243,10 @@ export function LabelerLabelPreference({
) : isGlobalLabel ? (
<Trans>
Configured in{' '}
<InlineLinkText to="/moderation" style={a.text_sm}>
<InlineLinkText
label={_(msg`moderation settings`)}
to="/moderation"
style={a.text_sm}>
moderation settings
</InlineLinkText>
.
+41 -14
View File
@@ -7,6 +7,7 @@ import {useMutation} from '@tanstack/react-query'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
@@ -127,6 +128,9 @@ function Label({
const t = useTheme()
const {_} = useLingui()
const {labeler, strings} = useLabelInfo(label)
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src
return (
<View
style={[
@@ -168,13 +172,12 @@ function Label({
<Trans>
Source:{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src}
{sourceName}
</InlineLinkText>
</Trans>
)}
@@ -201,26 +204,49 @@ function AppealForm({
const [details, setDetails] = React.useState('')
const isAccountReport = 'did' in subject
const agent = useAgent()
const gate = useGate()
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src
const {mutate, isPending} = useMutation({
mutationFn: async () => {
const $type = !isAccountReport
? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef'
await agent
.withProxy('atproto_labeler', label.src)
.createModerationReport({
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type,
...subject,
if (gate('session_withproxy_fix')) {
await agent.createModerationReport(
{
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type,
...subject,
},
reason: details,
},
reason: details,
})
{
encoding: 'application/json',
headers: {
'atproto-proxy': `${label.src}#atproto_labeler`,
},
},
)
} else {
await agent
.withProxy('atproto_labeler', label.src)
.createModerationReport({
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type,
...subject,
},
reason: details,
})
}
},
onError: err => {
logger.error('Failed to submit label appeal', {message: err})
Toast.show(_(msg`Failed to submit appeal, please try again.`))
Toast.show(_(msg`Failed to submit appeal, please try again.`), 'xmark')
},
onSuccess: () => {
control.close()
@@ -239,12 +265,13 @@ function AppealForm({
<Trans>
This appeal will be sent to{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}
style={[a.text_md, a.leading_snug]}>
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
{sourceName}
</InlineLinkText>
.
</Trans>
@@ -54,7 +54,10 @@ function ModerationDetailsDialogInner({
description = (
<Trans>
This user is included in the{' '}
<InlineLinkText to={listUriToHref(list.uri)} style={[a.text_sm]}>
<InlineLinkText
label={list.name}
to={listUriToHref(list.uri)}
style={[a.text_sm]}>
{list.name}
</InlineLinkText>{' '}
list which you have blocked.
@@ -83,7 +86,10 @@ function ModerationDetailsDialogInner({
description = (
<Trans>
This user is included in the{' '}
<InlineLinkText to={listUriToHref(list.uri)} style={[a.text_sm]}>
<InlineLinkText
label={list.name}
to={listUriToHref(list.uri)}
style={[a.text_sm]}>
{list.name}
</InlineLinkText>{' '}
list which you have muted.
@@ -127,10 +133,11 @@ function ModerationDetailsDialogInner({
<Trans>
This label was applied by{' '}
<InlineLinkText
label={desc.source || _(msg`an unknown labeler`)}
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source}
{desc.source || _(msg`an unknown labeler`)}
</InlineLinkText>
.
</Trans>
+23 -98
View File
@@ -1,25 +1,17 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {BSKY_LABELER_DID, ModerationCause, ModerationUI} from '@atproto/api'
import {StyleProp, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
import * as Pills from '#/components/Pills'
export function PostAlerts({
modui,
size,
size = 'sm',
style,
}: {
modui: ModerationUI
size?: 'medium' | 'large'
size?: Pills.CommonProps['size']
includeMute?: boolean
style?: StyleProp<ViewStyle>
}) {
@@ -28,90 +20,23 @@ export function PostAlerts({
}
return (
<View style={[a.flex_col, a.gap_xs, style]}>
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{modui.alerts.map(cause => (
<PostLabel
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
/>
))}
{modui.informs.map(cause => (
<PostLabel
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
/>
))}
</View>
</View>
)
}
function PostLabel({
cause,
size,
}: {
cause: ModerationCause
size?: 'medium' | 'large'
}) {
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
const t = useTheme()
return (
<>
<Button
label={desc.name}
onPress={e => {
e.preventDefault()
e.stopPropagation()
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.gap_xs,
a.rounded_sm,
hovered || pressed
? size === 'large'
? t.atoms.bg_contrast_50
: t.atoms.bg_contrast_25
: size === 'large'
? t.atoms.bg_contrast_25
: undefined,
size === 'large'
? {paddingLeft: 4, paddingRight: 6, paddingVertical: 2}
: {paddingRight: 4, paddingVertical: 1},
]}>
{desc.sourceType === 'labeler' &&
desc.sourceDid !== BSKY_LABELER_DID ? (
<UserAvatar
avatar={desc.sourceAvi}
size={size === 'large' ? 16 : 12}
type="labeler"
shape="circle"
/>
) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
)}
<Text
style={[
a.text_left,
a.leading_snug,
size === 'large' ? {fontSize: 13} : a.text_xs,
size === 'large' ? t.atoms.text : t.atoms.text_contrast_high,
]}>
{desc.name}
</Text>
</View>
)}
</Button>
<ModerationDetailsDialog control={control} modcause={cause} />
</>
<Pills.Row size={size} style={[size === 'sm' && {marginLeft: -3}, style]}>
{modui.alerts.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
noBg={size === 'sm'}
/>
))}
{modui.informs.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
noBg={size === 'sm'}
/>
))}
</Pills.Row>
)
}
@@ -1,25 +1,12 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {
BSKY_LABELER_DID,
ModerationCause,
ModerationDecision,
} from '@atproto/api'
import {StyleProp, ViewStyle} from 'react-native'
import {ModerationDecision} from '@atproto/api'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
import {getModerationCauseKey} from 'lib/moderation'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {
ModerationDetailsDialog,
useModerationDetailsDialogControl,
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
import * as Pills from '#/components/Pills'
export function ProfileHeaderAlerts({
moderation,
style,
}: {
moderation: ModerationDecision
style?: StyleProp<ViewStyle>
@@ -30,73 +17,21 @@ export function ProfileHeaderAlerts({
}
return (
<View style={[a.flex_col, a.gap_xs, style]}>
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{modui.alerts.map(cause => (
<ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
))}
{modui.informs.map(cause => (
<ProfileLabel key={getModerationCauseKey(cause)} cause={cause} />
))}
</View>
</View>
)
}
export function ProfileLabel({
cause,
disableDetailsDialog,
}: {
cause: ModerationCause
disableDetailsDialog?: boolean
}) {
const t = useTheme()
const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause)
return (
<>
<Button
disabled={disableDetailsDialog}
label={desc.name}
onPress={() => {
control.open()
}}>
{({hovered, pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
{paddingLeft: 6, paddingRight: 8, paddingVertical: 4},
a.gap_xs,
a.rounded_md,
hovered || pressed
? t.atoms.bg_contrast_50
: t.atoms.bg_contrast_25,
]}>
{desc.sourceType === 'labeler' &&
desc.sourceDid !== BSKY_LABELER_DID ? (
<UserAvatar avatar={desc.sourceAvi} size={16} />
) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
)}
<Text
style={[
a.text_left,
a.leading_snug,
a.text_sm,
t.atoms.text_contrast_medium,
a.font_semibold,
]}>
{desc.name}
</Text>
</View>
)}
</Button>
{!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)}
</>
<Pills.Row size="lg">
{modui.alerts.map(cause => (
<Pills.Label
size="lg"
key={getModerationCauseKey(cause)}
cause={cause}
/>
))}
{modui.informs.map(cause => (
<Pills.Label
size="lg"
key={getModerationCauseKey(cause)}
cause={cause}
/>
))}
</Pills.Row>
)
}
+3 -2
View File
@@ -1,7 +1,8 @@
import React, {ReactNode, createContext, useContext} from 'react'
import React, {createContext, ReactNode, useContext} from 'react'
import {TextStyle, ViewStyle} from 'react-native'
import {ThemeName} from '#/alf/types'
import {darkTheme, defaultTheme, dimTheme} from './themes'
import {ThemeName} from '#/alf/themes'
export type ColorScheme = 'light' | 'dark'
+70 -61
View File
@@ -14,29 +14,33 @@ export type FeedTunerFn = (
slices: FeedViewPostsSlice[],
) => FeedViewPostsSlice[]
type FeedSliceItem = {
post: AppBskyFeedDefs.PostView
reply?: AppBskyFeedDefs.ReplyRef
}
function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem {
return {
post: feedViewPost.post,
reply: feedViewPost.reply,
}
}
export class FeedViewPostsSlice {
_reactKey: string
isFlattenedReply = false
_feedPost: FeedViewPost
items: FeedSliceItem[]
constructor(public items: FeedViewPost[]) {
const item = items[0]
this._reactKey = `slice-${item.post.uri}-${
item.reason?.indexedAt || item.post.indexedAt
constructor(feedPost: FeedViewPost) {
this._feedPost = feedPost
this._reactKey = `slice-${feedPost.post.uri}-${
feedPost.reason?.indexedAt || feedPost.post.indexedAt
}`
this.items = [toSliceItem(feedPost)]
}
get uri() {
if (this.isFlattenedReply) {
return this.items[1].post.uri
}
return this.items[0].post.uri
}
get ts() {
if (this.items[0].reason?.indexedAt) {
return this.items[0].reason.indexedAt as string
}
return this.items[0].post.indexedAt
return this._feedPost.post.uri
}
get isThread() {
@@ -48,31 +52,42 @@ export class FeedViewPostsSlice {
)
}
get isFullThread() {
return this.isThread && !this.items[0].reply
}
get rootItem() {
if (this.isFlattenedReply) {
return this.items[1]
}
return this.items[0]
get isQuotePost() {
const embed = this._feedPost.post.embed
return (
AppBskyEmbedRecord.isView(embed) ||
AppBskyEmbedRecordWithMedia.isView(embed)
)
}
get isReply() {
return (
AppBskyFeedPost.isRecord(this.rootItem.post.record) &&
!!this.rootItem.post.record.reply
AppBskyFeedPost.isRecord(this._feedPost.post.record) &&
!!this._feedPost.post.record.reply
)
}
get source(): ReasonFeedSource | undefined {
return this.items.find(item => '__source' in item && !!item.__source)
?.__source as ReasonFeedSource
get reason() {
return '__source' in this._feedPost
? (this._feedPost.__source as ReasonFeedSource)
: this._feedPost.reason
}
get feedContext() {
return this.items.find(item => item.feedContext)?.feedContext
return this._feedPost.feedContext
}
get isRepost() {
const reason = this._feedPost.reason
return AppBskyFeedDefs.isReasonRepost(reason)
}
get includesThreadRoot() {
return !this.items[0].reply
}
get likeCount() {
return this._feedPost.post.likeCount ?? 0
}
containsUri(uri: string) {
@@ -97,24 +112,24 @@ export class FeedViewPostsSlice {
if (this.items[0].reply) {
const reply = this.items[0].reply
if (AppBskyFeedDefs.isPostView(reply.parent)) {
this.isFlattenedReply = true
this.items.splice(0, 0, {post: reply.parent})
}
}
}
isFollowingAllAuthors(userDid: string) {
const item = this.rootItem
if (item.post.author.did === userDid) {
const feedPost = this._feedPost
if (feedPost.post.author.did === userDid) {
return true
}
if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
const parent = item.reply?.parent
if (AppBskyFeedDefs.isPostView(feedPost.reply?.parent)) {
const parent = feedPost.reply?.parent
if (parent?.author.did === userDid) {
return true
}
return (
parent?.author.viewer?.following && item.post.author.viewer?.following
parent?.author.viewer?.following &&
feedPost.post.author.viewer?.following
)
}
return false
@@ -127,7 +142,7 @@ export class NoopFeedTuner {
feed: FeedViewPost[],
_opts?: {dryRun: boolean; maintainOrder: boolean},
): FeedViewPostsSlice[] {
return feed.map(item => new FeedViewPostsSlice([item]))
return feed.map(item => new FeedViewPostsSlice(item))
}
}
@@ -165,7 +180,7 @@ export class FeedTuner {
})
if (maintainOrder) {
slices = feed.map(item => new FeedViewPostsSlice([item]))
slices = feed.map(item => new FeedViewPostsSlice(item))
} else {
// arrange the posts into thread slices
for (let i = feed.length - 1; i >= 0; i--) {
@@ -192,7 +207,7 @@ export class FeedTuner {
}
}
slices.unshift(new FeedViewPostsSlice([item]))
slices.unshift(new FeedViewPostsSlice(item))
}
}
@@ -215,7 +230,7 @@ export class FeedTuner {
// turn non-threads with reply parents into threads
for (const slice of slices) {
if (!slice.isThread && !slice.items[0].reason && slice.items[0].reply) {
if (!slice.isThread && !slice.reason && slice.items[0].reply) {
const reply = slice.items[0].reply
if (
AppBskyFeedDefs.isPostView(reply.parent) &&
@@ -256,8 +271,7 @@ export class FeedTuner {
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
for (let i = slices.length - 1; i >= 0; i--) {
const reason = slices[i].rootItem.reason
if (AppBskyFeedDefs.isReasonRepost(reason)) {
if (slices[i].isRepost) {
slices.splice(i, 1)
}
}
@@ -266,11 +280,7 @@ export class FeedTuner {
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
for (let i = slices.length - 1; i >= 0; i--) {
const embed = slices[i].rootItem.post.embed
if (
AppBskyEmbedRecord.isView(embed) ||
AppBskyEmbedRecordWithMedia.isView(embed)
) {
if (slices[i].isQuotePost) {
slices.splice(i, 1)
}
}
@@ -315,19 +325,18 @@ export class FeedTuner {
// remove any replies without at least minLikes likes
for (let i = slices.length - 1; i >= 0; i--) {
const slice = slices[i]
if (slice.isFullThread || !slice.isReply) {
continue
}
const item = slice.rootItem
const isRepost = Boolean(item.reason)
if (isRepost) {
continue
}
if ((item.post.likeCount || 0) < minLikes) {
slices.splice(i, 1)
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
slices.splice(i, 1)
if (slice.isReply) {
if (slice.isThread && slice.includesThreadRoot) {
continue
}
if (slice.isRepost) {
continue
}
if (slice.likeCount < minLikes) {
slices.splice(i, 1)
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
slices.splice(i, 1)
}
}
}
return slices
+1 -1
View File
@@ -251,7 +251,7 @@ class MergeFeedSource_Following extends MergeFeedSource {
dryRun: false,
maintainOrder: true,
})
res.data.feed = slices.map(slice => slice.rootItem)
res.data.feed = slices.map(slice => slice._feedPost)
return res
}
}
+6 -4
View File
@@ -1,10 +1,10 @@
import {timeout} from './timeout'
export async function until(
export async function until<T>(
retries: number,
delay: number,
cond: (v: any, err: any) => boolean,
fn: () => Promise<any>,
cond: (v: T, err: any) => boolean,
fn: () => Promise<T>,
): Promise<boolean> {
while (retries > 0) {
try {
@@ -13,7 +13,9 @@ export async function until(
return true
}
} catch (e: any) {
if (cond(undefined, e)) {
// TODO: change the type signature of cond to accept undefined
// however this breaks every existing usage of until -sfn
if (cond(undefined as unknown as T, e)) {
return true
}
}
+11
View File
@@ -13,6 +13,15 @@ export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
// HACK
// Yes, this is exactly what it looks like. It's a hard-coded constant
// reflecting the number of new users in the last week. We don't have
// time to add a route to the servers for this so we're just going to hard
// code and update this number with each release until we can get the
// server route done.
// -prf
export const JOINED_THIS_WEEK = 21797 // as of Jul5 2024
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({
email,
@@ -124,3 +133,5 @@ export const GIF_SEARCH = (params: string) =>
`${GIF_SERVICE}/tenor/v2/search?${params}`
export const GIF_FEATURED = (params: string) =>
`${GIF_SERVICE}/tenor/v2/featured?${params}`
export const MAX_LABELERS = 20
+13 -1
View File
@@ -1,9 +1,12 @@
import React from 'react'
import * as Linking from 'expo-linking'
import {logEvent} from 'lib/statsig/statsig'
import {isNative} from 'platform/detection'
import {useComposerControls} from 'state/shell'
import {useSession} from 'state/session'
import {useComposerControls} from 'state/shell'
import {useCloseAllActiveElements} from 'state/util'
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
type IntentType = 'compose'
@@ -15,6 +18,15 @@ export function useIntentHandler() {
React.useEffect(() => {
const handleIncomingURL = (url: string) => {
const referrerInfo = Referrer.getReferrerInfo()
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
logEvent('deepLink:referrerReceived', {
to: url,
referrer: referrerInfo?.referrer,
hostname: referrerInfo?.hostname,
})
}
// We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
// slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
// of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
+32 -1
View File
@@ -20,7 +20,9 @@ const openPermissionAlert = (perm: string) => {
}
export function usePhotoLibraryPermission() {
const [res, requestPermission] = MediaLibrary.usePermissions()
const [res, requestPermission] = MediaLibrary.usePermissions({
granularPermissions: ['photo'],
})
const requestPhotoAccessIfNeeded = async () => {
// On the, we use <input type="file"> to produce a filepicker
// This does not need any permission granting.
@@ -46,6 +48,35 @@ export function usePhotoLibraryPermission() {
return {requestPhotoAccessIfNeeded}
}
export function useVideoLibraryPermission() {
const [res, requestPermission] = MediaLibrary.usePermissions({
granularPermissions: ['video'],
})
const requestVideoAccessIfNeeded = async () => {
// On the, we use <input type="file"> to produce a filepicker
// This does not need any permission granting.
if (isWeb) {
return true
}
if (res?.granted) {
return true
} else if (!res || res.status === 'undetermined' || res?.canAskAgain) {
const {canAskAgain, granted, status} = await requestPermission()
if (!canAskAgain && status === 'undetermined') {
openPermissionAlert('video library')
}
return granted
} else {
openPermissionAlert('video library')
return false
}
}
return {requestVideoAccessIfNeeded}
}
export function useCameraPermission() {
const [res, requestPermission] = Camera.useCameraPermissions()
+8
View File
@@ -14,3 +14,11 @@ export function useCameraPermission() {
return {requestCameraAccessIfNeeded}
}
export function useVideoLibraryPermission() {
const requestVideoAccessIfNeeded = async () => {
return true
}
return {requestVideoAccessIfNeeded}
}
+46 -5
View File
@@ -1,11 +1,16 @@
import {AppBskyFeedPost, BskyAgent} from '@atproto/api'
import {AppBskyFeedPost, AppBskyGraphStarterpack, BskyAgent} from '@atproto/api'
import {useFetchDid} from '#/state/queries/handle'
import {useGetPost} from '#/state/queries/post'
import * as apilib from 'lib/api/index'
import {LikelyType, LinkMeta} from './link-meta'
import {
createStarterPackUri,
parseStarterPackUri,
} from 'lib/strings/starter-pack'
import {ComposerOptsQuote} from 'state/shell/composer'
// import {match as matchRoute} from 'view/routes'
import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
import {ComposerOptsQuote} from 'state/shell/composer'
import {useGetPost} from '#/state/queries/post'
import {useFetchDid} from '#/state/queries/handle'
import {LikelyType, LinkMeta} from './link-meta'
// TODO
// import {Home} from 'view/screens/Home'
@@ -174,3 +179,39 @@ export async function getListAsEmbed(
},
}
}
export async function getStarterPackAsEmbed(
agent: BskyAgent,
fetchDid: ReturnType<typeof useFetchDid>,
url: string,
): Promise<apilib.ExternalEmbedDraft> {
const parsed = parseStarterPackUri(url)
if (!parsed) {
throw new Error(
'Unexepectedly called getStarterPackAsEmbed with a non-starterpack url',
)
}
const did = await fetchDid(parsed.name)
const starterPack = createStarterPackUri({did, rkey: parsed.rkey})
const res = await agent.app.bsky.graph.getStarterPack({starterPack})
const record = res.data.starterPack.record
return {
isLoading: false,
uri: starterPack,
meta: {
url: starterPack,
likelyType: LikelyType.AtpData,
// Validation here should never fail
title: AppBskyGraphStarterpack.isRecord(record)
? record.name
: 'Starter Pack',
},
embed: {
$type: 'app.bsky.embed.record',
record: {
uri: res.data.starterPack.uri,
cid: res.data.starterPack.cid,
},
},
}
}
+5 -3
View File
@@ -1,8 +1,10 @@
import {BskyAgent} from '@atproto/api'
import {isBskyAppUrl} from '../strings/url-helpers'
import {extractBskyMeta} from './bsky'
import {LINK_META_PROXY} from 'lib/constants'
import {getGiphyMetaUri} from 'lib/strings/embed-player'
import {parseStarterPackUri} from 'lib/strings/starter-pack'
import {isBskyAppUrl} from '../strings/url-helpers'
import {extractBskyMeta} from './bsky'
export enum LikelyType {
HTML,
@@ -28,7 +30,7 @@ export async function getLinkMeta(
url: string,
timeout = 15e3,
): Promise<LinkMeta> {
if (isBskyAppUrl(url)) {
if (isBskyAppUrl(url) && !parseStarterPackUri(url)) {
return extractBskyMeta(agent, url)
}
+27
View File
@@ -0,0 +1,27 @@
import {logger} from '#/logger'
export async function resolveShortLink(shortLink: string) {
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), 2e3)
try {
const res = await fetch(shortLink, {
method: 'GET',
headers: {
Accept: 'application/json',
},
signal: controller.signal,
})
if (res.status !== 200) {
logger.error('Failed to resolve short link', {status: res.status})
return shortLink
}
const json = (await res.json()) as {url: string}
return json.url
} catch (e: unknown) {
logger.error('Failed to resolve short link', {safeMessage: e})
return shortLink
} finally {
clearTimeout(to)
}
}
+2 -2
View File
@@ -17,14 +17,14 @@ export async function openPicker(opts?: ImagePickerOptions) {
})
if (response.assets && response.assets.length > 4) {
Toast.show('You may only select up to 4 images')
Toast.show('You may only select up to 4 images', 'exclamation-circle')
}
return (response.assets ?? [])
.slice(0, 4)
.filter(asset => {
if (asset.mimeType?.startsWith('image/')) return true
Toast.show('Only image files are supported')
Toast.show('Only image files are supported', 'exclamation-circle')
return false
})
.map(image => ({
+30
View File
@@ -0,0 +1,30 @@
import {getVideoMetaData, Video} from 'react-native-compressor'
export type CompressedVideo = {
uri: string
size: number
}
export async function compressVideo(
file: string,
opts?: {
getCancellationId?: (id: string) => void
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const {onProgress, getCancellationId} = opts || {}
const compressed = await Video.compress(
file,
{
getCancellationId,
compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps
maxSize: 1920,
},
onProgress,
)
const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size}
}
+28
View File
@@ -0,0 +1,28 @@
import {VideoTooLargeError} from 'lib/media/video/errors'
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
export type CompressedVideo = {
uri: string
size: number
}
// doesn't actually compress, but throws if >100MB
export async function compressVideo(
file: string,
_callbacks?: {
onProgress: (progress: number) => void
},
): Promise<CompressedVideo> {
const blob = await fetch(file).then(res => res.blob())
const video = URL.createObjectURL(blob)
if (blob.size > MAX_VIDEO_SIZE) {
throw new VideoTooLargeError()
}
return {
size: blob.size,
uri: video,
}
}
+6
View File
@@ -0,0 +1,6 @@
export class VideoTooLargeError extends Error {
constructor() {
super('Videos cannot be larger than 100MB')
this.name = 'VideoTooLargeError'
}
}
+17
View File
@@ -0,0 +1,17 @@
import {AppBskyActorDefs} from '@atproto/api'
export function isBlockedOrBlocking(
profile:
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed,
) {
return profile.viewer?.blockedBy || profile.viewer?.blocking
}
export function isMuted(
profile:
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed,
) {
return profile.viewer?.muted || profile.viewer?.mutedByList
}
@@ -12,10 +12,6 @@ export function createSanitizedDisplayName(
if (profile.displayName != null && profile.displayName !== '') {
return sanitizeDisplayName(profile.displayName)
} else {
let sanitizedHandle = sanitizeHandle(profile.handle)
if (!noAt) {
sanitizedHandle = `@${sanitizedHandle}`
}
return sanitizedHandle
return sanitizeHandle(profile.handle, noAt ? '' : '@')
}
}
+9 -9
View File
@@ -25,6 +25,7 @@ export type CommonNavigatorParams = {
ProfileLabelerLikedBy: {name: string}
Debug: undefined
DebugMod: undefined
SharedPreferencesTester: undefined
Log: undefined
Support: undefined
PrivacyPolicy: undefined
@@ -41,13 +42,13 @@ export type CommonNavigatorParams = {
Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string; embed?: string}
MessagesSettings: undefined
NotificationsSettings: undefined
Feeds: undefined
Start: {name: string; rkey: string}
StarterPack: {name: string; rkey: string; new?: boolean}
StarterPackShort: {code: string}
StarterPackWizard: undefined
StarterPackEdit: {
rkey?: string
}
StarterPackEdit: {rkey?: string}
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -67,7 +68,7 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
}
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
Notifications: undefined
Notifications: {show?: 'all'}
}
export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
@@ -82,7 +83,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Home: undefined
Search: {q?: string}
Feeds: undefined
Notifications: undefined
Notifications: {show?: 'all'}
Hashtag: {tag: string; author?: string}
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
}
@@ -94,17 +95,16 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Search: {q?: string}
Feeds: undefined
NotificationsTab: undefined
Notifications: undefined
Notifications: {show?: 'all'}
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
MessagesTab: undefined
Messages: {animation?: 'push' | 'pop'}
Start: {name: string; rkey: string}
StarterPack: {name: string; rkey: string; new?: boolean}
StarterPackShort: {code: string}
StarterPackWizard: undefined
StarterPackEdit: {
rkey?: string
}
StarterPackEdit: {rkey?: string}
}
// NOTE
+1 -1
View File
@@ -20,6 +20,6 @@ export async function shareUrl(url: string) {
// React Native Share is not supported by web. Web Share API
// has increasing but not full support, so default to clipboard
setStringAsync(url)
Toast.show('Copied to clipboard')
Toast.show('Copied to clipboard', 'clipboard-check')
}
}
+22
View File
@@ -25,13 +25,24 @@ export type LogEvents = {
}
'state:foreground:sampled': {}
'router:navigate:sampled': {}
'deepLink:referrerReceived': {
to: string
referrer: string
hostname: string
}
// Screen events
'splash:signInPressed': {}
'splash:createAccountPressed': {}
'signup:nextPressed': {
activeStep: number
phoneVerificationRequired?: boolean
}
'signup:backPressed': {
activeStep: number
}
'signup:captchaSuccess': {}
'signup:captchaFailure': {}
'onboarding:interests:nextPressed': {
selectedInterests: string[]
selectedInterestsLength: number
@@ -147,6 +158,7 @@ export type LogEvents = {
| 'ProfileHoverCard'
| 'AvatarButton'
| 'StarterPackProfilesList'
| 'FeedInterstitial'
}
'profile:unfollow': {
logContext:
@@ -160,6 +172,7 @@ export type LogEvents = {
| 'Chat'
| 'AvatarButton'
| 'StarterPackProfilesList'
| 'FeedInterstitial'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -188,6 +201,15 @@ export type LogEvents = {
profilesCount: number
feedsCount: number
}
'starterPack:ctaPress': {
starterPack: string
}
'starterPack:opened': {
starterPack: string
}
'feed:interstitial:profileCard:press': {}
'feed:interstitial:feedCard:press': {}
'test:all:always': {}
'test:all:sometimes': {}
+10 -1
View File
@@ -1,8 +1,17 @@
export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
| 'explore_page_profile_card_social_proof'
| 'native_pwi_disabled'
| 'new_user_guided_tour'
| 'new_user_progress_guide'
| 'onboarding_minimum_interests'
| 'request_notifications_permission_after_onboarding_v2'
| 'session_withproxy_fix'
| 'show_avi_follow_button'
| 'show_follow_back_label_v2'
| 'starter_packs_enabled'
| 'suggested_feeds_interstitial'
| 'suggested_follows_interstitial'
| 'ungroup_follow_backs'
| 'videos'
| 'small_avi_thumb'
-21
View File
@@ -28,8 +28,6 @@ type StatsigUser = {
bundleDate: number
refSrc: string
refUrl: string
referrer: string
referrerHostname: string
appLanguage: string
contentLanguages: string[]
}
@@ -37,29 +35,12 @@ type StatsigUser = {
let refSrc = ''
let refUrl = ''
let referrer = ''
let referrerHostname = ''
if (isWeb && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search)
refSrc = params.get('ref_src') ?? ''
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
}
if (
isWeb &&
typeof document !== 'undefined' &&
document != null &&
document.referrer
) {
try {
const url = new URL(document.referrer)
if (url.hostname !== 'bsky.app') {
referrer = document.referrer
referrerHostname = url.hostname
}
} catch {}
}
export type {LogEvents}
function createStatsigOptions(prefetchUsers: StatsigUser[]) {
@@ -222,8 +203,6 @@ function toStatsigUser(did: string | undefined): StatsigUser {
custom: {
refSrc,
refUrl,
referrer,
referrerHostname,
platform: Platform.OS as 'ios' | 'android' | 'web',
bundleIdentifier: BUNDLE_IDENTIFIER,
bundleDate: BUNDLE_DATE,
+10
View File
@@ -0,0 +1,10 @@
const LEFT_TO_RIGHT_EMBEDDING = '\u202A'
const POP_DIRECTIONAL_FORMATTING = '\u202C'
/*
* Force LTR directionality in a string.
* https://www.unicode.org/reports/tr9/#Directional_Formatting_Characters
*/
export function forceLTR(str: string) {
return LEFT_TO_RIGHT_EMBEDDING + str + POP_DIRECTIONAL_FORMATTING
}
+1
View File
@@ -0,0 +1 @@
export const NON_BREAKING_SPACE = '\u00A0'
+5 -1
View File
@@ -1,5 +1,7 @@
// Regex from the go implementation
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
import {forceLTR} from 'lib/strings/bidi'
const VALIDATE_REGEX =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
@@ -22,7 +24,9 @@ export function isInvalidHandle(handle: string): boolean {
}
export function sanitizeHandle(handle: string, prefix = ''): string {
return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}`
return isInvalidHandle(handle)
? '⚠Invalid Handle'
: forceLTR(`${prefix}${handle}`)
}
export interface IsValidHandle {
+5 -1
View File
@@ -96,6 +96,10 @@ export function createStarterPackUri({
}: {
did: string
rkey: string
}): string | null {
}): string {
return new AtUri(`at://${did}/app.bsky.graph.starterpack/${rkey}`).toString()
}
export function startUriToStarterPackUri(uri: string) {
return uri.replace('/start/', '/starter-pack/')
}
+54
View File
@@ -2,8 +2,10 @@ import {AtUri} from '@atproto/api'
import psl from 'psl'
import TLDs from 'tlds'
import {logger} from '#/logger'
import {BSKY_SERVICE} from 'lib/constants'
import {isInvalidHandle} from 'lib/strings/handles'
import {startUriToStarterPackUri} from 'lib/strings/starter-pack'
export const BSKY_APP_HOST = 'https://bsky.app'
const BSKY_TRUSTED_HOSTS = [
@@ -151,6 +153,30 @@ export function isBskyListUrl(url: string): boolean {
return false
}
export function isBskyStartUrl(url: string): boolean {
if (isBskyAppUrl(url)) {
try {
const urlp = new URL(url)
return /start\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
} catch {
console.error('Unexpected error in isBskyStartUrl()', url)
}
}
return false
}
export function isBskyStarterPackUrl(url: string): boolean {
if (isBskyAppUrl(url)) {
try {
const urlp = new URL(url)
return /starter-pack\/(?<name>[^/]+)\/(?<rkey>[^/]+)/i.test(urlp.pathname)
} catch {
console.error('Unexpected error in isBskyStartUrl()', url)
}
}
return false
}
export function isBskyDownloadUrl(url: string): boolean {
if (isExternalUrl(url)) {
return false
@@ -162,10 +188,18 @@ export function convertBskyAppUrlIfNeeded(url: string): string {
if (isBskyAppUrl(url)) {
try {
const urlp = new URL(url)
if (isBskyStartUrl(url)) {
return startUriToStarterPackUri(urlp.pathname)
}
return urlp.pathname
} catch (e) {
console.error('Unexpected error in convertBskyAppUrlIfNeeded()', e)
}
} else if (isShortLink(url)) {
// We only want to do this on native, web handles the 301 for us
return shortLinkToHref(url)
}
return url
}
@@ -285,3 +319,23 @@ export function createBskyAppAbsoluteUrl(path: string): string {
const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
}
export function isShortLink(url: string): boolean {
return url.startsWith('https://go.bsky.app/')
}
export function shortLinkToHref(url: string): string {
try {
const urlp = new URL(url)
// For now we only support starter packs, but in the future we should add additional paths to this check
const parts = urlp.pathname.split('/').filter(Boolean)
if (parts.length === 1) {
return `/starter-pack-short/${parts[0]}`
}
return url
} catch (e) {
logger.error('Failed to parse possible short link', {safeMessage: e})
return url
}
}
+7 -7
View File
@@ -1,8 +1,8 @@
import {Platform} from 'react-native'
import type {Theme} from './ThemeContext'
import {colors} from './styles'
import {darkPalette, lightPalette, dimPalette} from '#/alf/themes'
import {darkPalette, dimPalette, lightPalette} from '#/alf/themes'
import {colors} from './styles'
import type {Theme} from './ThemeContext'
export const defaultTheme: Theme = {
colorScheme: 'light',
@@ -308,8 +308,8 @@ export const darkTheme: Theme = {
textVeryLight: darkPalette.contrast_400,
replyLine: darkPalette.contrast_200,
replyLineDot: darkPalette.contrast_200,
unreadNotifBg: darkPalette.primary_975,
unreadNotifBorder: darkPalette.primary_900,
unreadNotifBg: darkPalette.primary_25,
unreadNotifBorder: darkPalette.primary_100,
postCtrl: darkPalette.contrast_500,
brandText: darkPalette.primary_500,
emptyStateIcon: darkPalette.contrast_300,
@@ -357,8 +357,8 @@ export const dimTheme: Theme = {
textVeryLight: dimPalette.contrast_400,
replyLine: dimPalette.contrast_200,
replyLineDot: dimPalette.contrast_200,
unreadNotifBg: dimPalette.primary_975,
unreadNotifBorder: dimPalette.primary_900,
unreadNotifBg: dimPalette.primary_25,
unreadNotifBorder: dimPalette.primary_100,
postCtrl: dimPalette.contrast_500,
brandText: dimPalette.primary_500,
emptyStateIcon: dimPalette.contrast_300,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More