Merge remote-tracking branch 'origin/main' into hailey/onboarding-profile
# Conflicts: # src/screens/Onboarding/StepModeration/index.tsx
This commit is contained in:
+28
-19
@@ -5,7 +5,7 @@ import React, {useState, useEffect} from 'react'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
|
||||
import {
|
||||
SafeAreaProvider,
|
||||
initialWindowMetrics,
|
||||
@@ -22,7 +22,11 @@ import {s} from 'lib/styles'
|
||||
import {Shell} from 'view/shell'
|
||||
import * as notifications from 'lib/notifications/notifications'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {
|
||||
queryClient,
|
||||
asyncStoragePersister,
|
||||
dehydrateOptions,
|
||||
} from 'lib/react-query'
|
||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
@@ -33,6 +37,7 @@ import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
@@ -79,21 +84,23 @@ function InnerApp() {
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</React.Fragment>
|
||||
</Splash>
|
||||
@@ -118,7 +125,9 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
@@ -140,7 +149,7 @@ function App() {
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+28
-19
@@ -1,7 +1,7 @@
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {QueryClientProvider} from '@tanstack/react-query'
|
||||
import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
|
||||
@@ -13,7 +13,11 @@ import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {
|
||||
queryClient,
|
||||
asyncStoragePersister,
|
||||
dehydrateOptions,
|
||||
} from 'lib/react-query'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||
@@ -23,6 +27,7 @@ import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
@@ -56,21 +61,23 @@ function InnerApp() {
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</React.Fragment>
|
||||
</Alf>
|
||||
@@ -93,7 +100,9 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
@@ -115,7 +124,7 @@ function App() {
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
</PersistQueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -46,7 +46,7 @@ import {SearchScreen} from './view/screens/Search'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {ListsScreen} from './view/screens/Lists'
|
||||
import {ModerationScreen} from './view/screens/Moderation'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||
import {NotFoundScreen} from './view/screens/NotFound'
|
||||
import {SettingsScreen} from './view/screens/Settings'
|
||||
@@ -61,6 +61,7 @@ import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
import {Storybook} from './view/screens/Storybook'
|
||||
import {DebugModScreen} from './view/screens/DebugMod'
|
||||
import {LogScreen} from './view/screens/Log'
|
||||
import {SupportScreen} from './view/screens/Support'
|
||||
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
||||
@@ -78,6 +79,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack
|
||||
import {msg} from '@lingui/macro'
|
||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
@@ -198,11 +200,21 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => ProfileFeedLikedByScreen}
|
||||
options={{title: title(msg`Liked by`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileLabelerLikedBy"
|
||||
getComponent={() => ProfileLabelerLikedByScreen}
|
||||
options={{title: title(msg`Liked by`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Debug"
|
||||
getComponent={() => Storybook}
|
||||
options={{title: title(msg`Storybook`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="DebugMod"
|
||||
getComponent={() => DebugModScreen}
|
||||
options={{title: title(msg`Moderation states`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Log"
|
||||
getComponent={() => LogScreen}
|
||||
|
||||
@@ -50,6 +50,9 @@ export const atoms = {
|
||||
h_full: {
|
||||
height: '100%',
|
||||
},
|
||||
h_full_vh: web({
|
||||
height: '100vh',
|
||||
}),
|
||||
|
||||
/*
|
||||
* Border radius
|
||||
@@ -243,6 +246,9 @@ export const atoms = {
|
||||
font_normal: {
|
||||
fontWeight: tokens.fontWeight.normal,
|
||||
},
|
||||
font_semibold: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
font_bold: {
|
||||
fontWeight: tokens.fontWeight.semibold,
|
||||
},
|
||||
@@ -528,6 +534,10 @@ export const atoms = {
|
||||
/*
|
||||
* Margin
|
||||
*/
|
||||
mx_auto: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
m_2xs: {
|
||||
margin: tokens.space._2xs,
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ type BreakpointName = keyof typeof breakpoints
|
||||
const breakpoints: {
|
||||
[key: string]: number
|
||||
} = {
|
||||
gtPhone: 500,
|
||||
gtMobile: 800,
|
||||
gtTablet: 1300,
|
||||
}
|
||||
@@ -26,6 +27,7 @@ function getActiveBreakpoints({width}: {width: number}) {
|
||||
|
||||
return {
|
||||
active: active[active.length - 1],
|
||||
gtPhone: active.includes('gtPhone'),
|
||||
gtMobile: active.includes('gtMobile'),
|
||||
gtTablet: active.includes('gtTablet'),
|
||||
}
|
||||
@@ -39,6 +41,7 @@ export const Context = React.createContext<{
|
||||
theme: themes.Theme
|
||||
breakpoints: {
|
||||
active: BreakpointName | undefined
|
||||
gtPhone: boolean
|
||||
gtMobile: boolean
|
||||
gtTablet: boolean
|
||||
}
|
||||
@@ -47,6 +50,7 @@ export const Context = React.createContext<{
|
||||
theme: themes.light,
|
||||
breakpoints: {
|
||||
active: undefined,
|
||||
gtPhone: false,
|
||||
gtMobile: false,
|
||||
gtTablet: false,
|
||||
},
|
||||
|
||||
@@ -12,6 +12,9 @@ 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]}%)`,
|
||||
|
||||
+14
-14
@@ -15,6 +15,7 @@ import LinearGradient from 'react-native-linear-gradient'
|
||||
|
||||
import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {normalizeTextStyles} from '#/components/Typography'
|
||||
|
||||
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
|
||||
export type ButtonColor =
|
||||
@@ -139,7 +140,7 @@ export function Button({
|
||||
}))
|
||||
}, [setState])
|
||||
|
||||
const {baseStyles, hoverStyles, focusStyles} = React.useMemo(() => {
|
||||
const {baseStyles, hoverStyles} = React.useMemo(() => {
|
||||
const baseStyles: ViewStyle[] = []
|
||||
const hoverStyles: ViewStyle[] = []
|
||||
const light = t.name === 'light'
|
||||
@@ -191,14 +192,14 @@ export function Button({
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
backgroundColor: t.palette.contrast_25,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.contrast_200,
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
@@ -308,12 +309,6 @@ export function Button({
|
||||
return {
|
||||
baseStyles,
|
||||
hoverStyles,
|
||||
focusStyles: [
|
||||
...hoverStyles,
|
||||
{
|
||||
outline: 0,
|
||||
} as ViewStyle,
|
||||
],
|
||||
}
|
||||
}, [t, variant, color, size, shape, disabled])
|
||||
|
||||
@@ -376,10 +371,8 @@ export function Button({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.justify_center,
|
||||
flattenedBaseStyles,
|
||||
...(state.hovered || state.pressed ? hoverStyles : []),
|
||||
...(state.focused ? focusStyles : []),
|
||||
flatten(style),
|
||||
]}
|
||||
onPressIn={onPressIn}
|
||||
@@ -398,7 +391,7 @@ export function Button({
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={
|
||||
state.hovered || state.pressed || state.focused
|
||||
state.hovered || state.pressed
|
||||
? gradientHoverColors
|
||||
: gradientColors
|
||||
}
|
||||
@@ -527,7 +520,14 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
|
||||
const textStyles = useSharedButtonTextStyles()
|
||||
|
||||
return (
|
||||
<Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
|
||||
<Text
|
||||
{...rest}
|
||||
style={normalizeTextStyles([
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
textStyles,
|
||||
style,
|
||||
])}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -99,7 +99,7 @@ export function Outer({
|
||||
style={[
|
||||
web(a.fixed),
|
||||
a.inset_0,
|
||||
{opacity: 0.5, backgroundColor: t.palette.black},
|
||||
{opacity: 0.8, backgroundColor: t.palette.black},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import type {AccessibilityProps} from 'react-native'
|
||||
import type {AccessibilityProps, GestureResponderEvent} from 'react-native'
|
||||
import {BottomSheetProps} from '@gorhom/bottom-sheet'
|
||||
|
||||
import {ViewStyleProp} from '#/alf'
|
||||
@@ -10,9 +10,15 @@ type A11yProps = Required<AccessibilityProps>
|
||||
* Mutated by useImperativeHandle to provide a public API for controlling the
|
||||
* dialog. The methods here will actually become the handlers defined within
|
||||
* the `Dialog.Outer` component.
|
||||
*
|
||||
* `Partial<GestureResponderEvent>` here allows us to add this directly to the
|
||||
* `onPress` prop of a button, for example. If this type was not added, we
|
||||
* would need to create a function to wrap `.open()` with.
|
||||
*/
|
||||
export type DialogControlRefProps = {
|
||||
open: (options?: DialogControlOpenOptions) => void
|
||||
open: (
|
||||
options?: DialogControlOpenOptions & Partial<GestureResponderEvent>,
|
||||
) => void
|
||||
close: (callback?: () => void) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react'
|
||||
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {View} from 'react-native'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export function Error({
|
||||
title,
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
title?: string
|
||||
message?: string
|
||||
onRetry?: () => unknown
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const canGoBack = navigation.canGoBack()
|
||||
const onGoBack = React.useCallback(() => {
|
||||
if (canGoBack) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
|
||||
// Checking the state for routes ensures that web doesn't encounter errors while going back
|
||||
if (navigation.getState()?.routes) {
|
||||
navigation.dispatch(StackActions.push(...router.matchPath('/')))
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
}
|
||||
}
|
||||
}, [navigation, canGoBack])
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>{title}</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_high,
|
||||
{lineHeight: 1.4},
|
||||
gtMobile && {width: 450},
|
||||
]}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label="Click here"
|
||||
onPress={onRetry}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label="Click here"
|
||||
onPress={onGoBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
Go Back
|
||||
</Button>
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
|
||||
export function GradientFill({
|
||||
gradient,
|
||||
}: {
|
||||
gradient:
|
||||
| typeof tokens.gradients.sky
|
||||
| typeof tokens.gradients.midnight
|
||||
| typeof tokens.gradients.sunrise
|
||||
| typeof tokens.gradients.sunset
|
||||
| typeof tokens.gradients.bonfire
|
||||
| typeof tokens.gradients.summer
|
||||
| typeof tokens.gradients.nordic
|
||||
}) {
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={gradient.values.map(c => c[1])}
|
||||
locations={gradient.values.map(c => c[0])}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useLabelerInfoQuery} from '#/state/queries/labeler'
|
||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
|
||||
type LabelingServiceProps = {
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
}
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<ViewStyleProp>) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.w_full,
|
||||
a.p_lg,
|
||||
a.pr_md,
|
||||
a.overflow_hidden,
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Avatar({avatar}: {avatar?: string}) {
|
||||
return <UserAvatar type="labeler" size={40} avatar={avatar} />
|
||||
}
|
||||
|
||||
export function Title({value}: {value: string}) {
|
||||
return <Text style={[a.text_md, a.font_bold]}>{value}</Text>
|
||||
}
|
||||
|
||||
export function Description({value, handle}: {value?: string; handle: string}) {
|
||||
return value ? (
|
||||
<Text numberOfLines={2}>
|
||||
<RichText value={value} style={[]} />
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
<Trans>By {sanitizeHandle(handle, '@')}</Trans>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function LikeCount({count}: {count: number}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
a.mt_sm,
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
{fontWeight: '500'},
|
||||
]}>
|
||||
<Trans>
|
||||
Liked by {count} {pluralize(count, 'user')}
|
||||
</Trans>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function Content({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
]}>
|
||||
<View style={[a.gap_xs, a.flex_1]}>{children}</View>
|
||||
|
||||
<ChevronRight size="md" style={[a.z_10, t.atoms.text_contrast_low]} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical view for a labeling service. Use this or compose your own.
|
||||
*/
|
||||
export function Default({
|
||||
labeler,
|
||||
style,
|
||||
}: LabelingServiceProps & ViewStyleProp) {
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<Avatar avatar={labeler.creator.avatar} />
|
||||
<Content>
|
||||
<Title
|
||||
value={getLabelingServiceTitle({
|
||||
displayName: labeler.creator.displayName,
|
||||
handle: labeler.creator.handle,
|
||||
})}
|
||||
/>
|
||||
<Description
|
||||
value={labeler.creator.description}
|
||||
handle={labeler.creator.handle}
|
||||
/>
|
||||
{labeler.likeCount ? <LikeCount count={labeler.likeCount} /> : null}
|
||||
</Content>
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function Link({
|
||||
children,
|
||||
labeler,
|
||||
}: LabelingServiceProps & Pick<LinkProps, 'children'>) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<InternalLink
|
||||
to={{
|
||||
screen: 'Profile',
|
||||
params: {
|
||||
name: labeler.creator.handle,
|
||||
},
|
||||
}}
|
||||
label={_(
|
||||
msg`View the labeling service provided by @${labeler.creator.handle}`,
|
||||
)}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
)
|
||||
}
|
||||
|
||||
// TODO not finished yet
|
||||
export function DefaultSkeleton() {
|
||||
return (
|
||||
<View>
|
||||
<Text>Loading</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Loader({
|
||||
did,
|
||||
loading: LoadingComponent = DefaultSkeleton,
|
||||
error: ErrorComponent,
|
||||
component: Component,
|
||||
}: {
|
||||
did: string
|
||||
loading?: React.ComponentType<{}>
|
||||
error?: React.ComponentType<{error: string}>
|
||||
component: React.ComponentType<{
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
}>
|
||||
}) {
|
||||
const {isLoading, data, error} = useLabelerInfoQuery({did})
|
||||
|
||||
return isLoading ? (
|
||||
LoadingComponent ? (
|
||||
<LoadingComponent />
|
||||
) : null
|
||||
) : error || !data ? (
|
||||
ErrorComponent ? (
|
||||
<ErrorComponent error={error?.message || 'Unknown error'} />
|
||||
) : null
|
||||
) : (
|
||||
<Component labeler={data} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function LikedByList({uri}: {uri: string}) {
|
||||
const t = useTheme()
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {
|
||||
data: resolvedUri,
|
||||
error: resolveError,
|
||||
isFetching: isFetchingResolvedUri,
|
||||
} = useResolveUriQuery(uri)
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isRefetching,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
error: likedByError,
|
||||
refetch,
|
||||
} = useLikedByQuery(resolvedUri?.uri)
|
||||
const likes = React.useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.likes)
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const error = resolveError || likedByError
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
try {
|
||||
await refetch()
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh likes', {message: err})
|
||||
}
|
||||
setIsPTRing(false)
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more likes', {message: err})
|
||||
}
|
||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => {
|
||||
return (
|
||||
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (isFetchingResolvedUri || !isFetched) {
|
||||
return (
|
||||
<View style={[a.w_full, a.align_center, a.p_lg]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return likes.length ? (
|
||||
<List
|
||||
data={likes}
|
||||
keyExtractor={item => item.actor.did}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={3}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={initialNumToRender}
|
||||
ListFooterComponent={() => (
|
||||
<ListFooter
|
||||
isFetching={isFetching && !isRefetching}
|
||||
isError={isError}
|
||||
error={error ? error.toString() : undefined}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<View style={[a.p_lg]}>
|
||||
<View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Nobody has liked this yet. Maybe you should be the first!
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, {useMemo, useCallback} from 'react'
|
||||
import {ActivityIndicator, FlatList, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {Loader} from '#/components/Loader'
|
||||
|
||||
interface LikesDialogProps {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
uri: string
|
||||
}
|
||||
|
||||
export function LikesDialog(props: LikesDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<LikesDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function LikesDialogInner({control, uri}: LikesDialogProps) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {
|
||||
data: resolvedUri,
|
||||
error: resolveError,
|
||||
isFetched: hasFetchedResolvedUri,
|
||||
} = useResolveUriQuery(uri)
|
||||
const {
|
||||
data,
|
||||
isFetching: isFetchingLikedBy,
|
||||
isFetched: hasFetchedLikedBy,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
error: likedByError,
|
||||
} = useLikedByQuery(resolvedUri?.uri)
|
||||
|
||||
const isLoading = !hasFetchedResolvedUri || !hasFetchedLikedBy
|
||||
const likes = useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.likes)
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetchingLikedBy || !hasNextPage || isError) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more likes', {message: err})
|
||||
}
|
||||
}, [isFetchingLikedBy, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item}: {item: GetLikes.Like}) => {
|
||||
return (
|
||||
<ProfileCardWithFollowBtn
|
||||
key={item.actor.did}
|
||||
profile={item.actor}
|
||||
onPress={() => control.close()}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[control],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Inner label={_(msg`Users that have liked this content or profile`)}>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_lg]}>
|
||||
<Trans>Liked by</Trans>
|
||||
</Text>
|
||||
|
||||
{isLoading ? (
|
||||
<View style={{minHeight: 300}}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : resolveError || likedByError || !data ? (
|
||||
<ErrorMessage message={cleanError(resolveError || likedByError)} />
|
||||
) : likes.length === 0 ? (
|
||||
<View style={[t.atoms.bg_contrast_50, a.px_md, a.py_xl, a.rounded_md]}>
|
||||
<Text style={[a.text_center]}>
|
||||
<Trans>
|
||||
Nobody has liked this yet. Maybe you should be the first!
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={likes}
|
||||
keyExtractor={item => item.actor.did}
|
||||
onEndReached={onEndReached}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={15}
|
||||
ListFooterComponent={
|
||||
<ListFooterComponent isFetching={isFetchingNextPage} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.Inner>
|
||||
)
|
||||
}
|
||||
|
||||
function ListFooterComponent({isFetching}: {isFetching: boolean}) {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<View style={a.pt_lg}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export function InlineLink({
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const flattenedStyle = flatten(style)
|
||||
const flattenedStyle = flatten(style) || {}
|
||||
|
||||
return (
|
||||
<Text
|
||||
|
||||
+67
-115
@@ -1,26 +1,28 @@
|
||||
import React from 'react'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
import {router} from '#/routes'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
import {Error} from '#/components/Error'
|
||||
|
||||
export function ListFooter({
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
height,
|
||||
}: {
|
||||
isFetching: boolean
|
||||
isError: boolean
|
||||
isFetching?: boolean
|
||||
isError?: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
height?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -29,11 +31,10 @@ export function ListFooter({
|
||||
style={[
|
||||
a.w_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.border_t,
|
||||
a.pb_lg,
|
||||
t.atoms.border_contrast_low,
|
||||
{height: 100},
|
||||
{height: height ?? 180, paddingTop: 30},
|
||||
]}>
|
||||
{isFetching ? (
|
||||
<Loader size="xl" />
|
||||
@@ -53,11 +54,12 @@ function ListFooterMaybeError({
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
isError: boolean
|
||||
isError?: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
if (!isError) return null
|
||||
|
||||
@@ -83,7 +85,7 @@ function ListFooterMaybeError({
|
||||
</Text>
|
||||
<Button
|
||||
variant="gradient"
|
||||
label="Press to retry"
|
||||
label={_(msg`Press to retry`)}
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
@@ -93,7 +95,7 @@ function ListFooterMaybeError({
|
||||
a.py_sm,
|
||||
]}
|
||||
onPress={onRetry}>
|
||||
Retry
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -128,121 +130,71 @@ export function ListMaybePlaceholder({
|
||||
isLoading,
|
||||
isEmpty,
|
||||
isError,
|
||||
empty,
|
||||
error,
|
||||
notFoundType = 'page',
|
||||
emptyTitle,
|
||||
emptyMessage,
|
||||
errorTitle,
|
||||
errorMessage,
|
||||
emptyType = 'page',
|
||||
onRetry,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isEmpty: boolean
|
||||
isError: boolean
|
||||
empty?: string
|
||||
error?: string
|
||||
notFoundType?: 'page' | 'results'
|
||||
isEmpty?: boolean
|
||||
isError?: boolean
|
||||
emptyTitle?: string
|
||||
emptyMessage?: string
|
||||
errorTitle?: string
|
||||
errorMessage?: string
|
||||
emptyType?: 'page' | 'results'
|
||||
onRetry?: () => Promise<unknown>
|
||||
}) {
|
||||
const navigation = useNavigationDeduped()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile, gtTablet} = useBreakpoints()
|
||||
|
||||
const canGoBack = navigation.canGoBack()
|
||||
const onGoBack = React.useCallback(() => {
|
||||
if (canGoBack) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
if (!isLoading && isError) {
|
||||
return (
|
||||
<Error
|
||||
title={errorTitle ?? _(msg`Oops!`)}
|
||||
message={errorMessage ?? _(`Something went wrong!`)}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Checking the state for routes ensures that web doesn't encounter errors while going back
|
||||
if (navigation.getState()?.routes) {
|
||||
navigation.dispatch(StackActions.push(...router.matchPath('/')))
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
}
|
||||
}
|
||||
}, [navigation, canGoBack])
|
||||
|
||||
if (!isEmpty) return null
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders={gtMobile}
|
||||
topBorder={!gtTablet}>
|
||||
{isLoading ? (
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders={gtMobile}
|
||||
topBorder={!gtTablet}>
|
||||
<View style={[a.w_full, a.align_center, {top: 100}]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
{isError ? (
|
||||
<Trans>Oops!</Trans>
|
||||
) : isEmpty ? (
|
||||
<>
|
||||
{notFoundType === 'results' ? (
|
||||
<Trans>No results found</Trans>
|
||||
) : (
|
||||
<Trans>Page not found</Trans>
|
||||
)}
|
||||
</>
|
||||
) : undefined}
|
||||
</Text>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
{isError ? (
|
||||
<Text
|
||||
style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
|
||||
{error ? error : <Trans>Something went wrong!</Trans>}
|
||||
</Text>
|
||||
) : isEmpty ? (
|
||||
<Text
|
||||
style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
|
||||
{empty ? (
|
||||
empty
|
||||
) : (
|
||||
<Trans>
|
||||
We're sorry! We can't find the page you were looking for.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
<View
|
||||
style={[a.gap_md, !gtMobile ? [a.w_full, a.px_lg] : {width: 350}]}>
|
||||
{isError && onRetry && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label="Click here"
|
||||
onPress={onRetry}
|
||||
size="large"
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
{paddingVertical: 10},
|
||||
]}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color={isError && onRetry ? 'secondary' : 'primary'}
|
||||
label="Click here"
|
||||
onPress={onGoBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
Go Back
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</CenteredView>
|
||||
)
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<Error
|
||||
title={
|
||||
emptyTitle ??
|
||||
(emptyType === 'results'
|
||||
? _(msg`No results found`)
|
||||
: _(msg`Page not found`))
|
||||
}
|
||||
message={
|
||||
emptyMessage ??
|
||||
_(msg`We're sorry! We can't find the page you were looking for.`)
|
||||
}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ItemIconProps,
|
||||
} from '#/components/Menu/types'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {isNative} from 'platform/detection'
|
||||
|
||||
@@ -209,7 +209,9 @@ function Cancel() {
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
|
||||
style={flatten([
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.gap_lg,
|
||||
a.py_sm,
|
||||
a.rounded_xs,
|
||||
{minHeight: 32, paddingHorizontal: 10},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
|
||||
import {atoms as a, useTheme, useBreakpoints} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, useButtonContext} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import * as LabelingServiceCard from '#/components/LabelingServiceCard'
|
||||
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function SelectLabelerView({
|
||||
...props
|
||||
}: ReportDialogProps & {
|
||||
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||
onSelectLabeler: (v: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
return (
|
||||
<View style={[a.gap_lg]}>
|
||||
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Select moderator</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>To whom would you like to send this report?</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.gap_xs, {marginHorizontal: a.p_md.padding * -1}]}>
|
||||
{props.labelers.map(labeler => {
|
||||
return (
|
||||
<Button
|
||||
key={labeler.creator.did}
|
||||
label={_(msg`Send report to ${labeler.creator.displayName}`)}
|
||||
onPress={() => props.onSelectLabeler(labeler.creator.did)}>
|
||||
<LabelerButton labeler={labeler} />
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function LabelerButton({
|
||||
labeler,
|
||||
}: {
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {hovered, pressed} = useButtonContext()
|
||||
const interacted = hovered || pressed
|
||||
|
||||
const styles = React.useMemo(() => {
|
||||
return {
|
||||
interacted: {
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
},
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<LabelingServiceCard.Outer
|
||||
style={[a.p_md, a.rounded_sm, interacted && styles.interacted]}>
|
||||
<LabelingServiceCard.Avatar avatar={labeler.creator.avatar} />
|
||||
<LabelingServiceCard.Content>
|
||||
<LabelingServiceCard.Title
|
||||
value={getLabelingServiceTitle({
|
||||
displayName: labeler.creator.displayName,
|
||||
handle: labeler.creator.handle,
|
||||
})}
|
||||
/>
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.text_sm, a.font_semibold]}>
|
||||
@{labeler.creator.handle}
|
||||
</Text>
|
||||
</LabelingServiceCard.Content>
|
||||
</LabelingServiceCard.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {DMCA_LINK} from '#/components/ReportDialog/const'
|
||||
import {Link} from '#/components/Link'
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {atoms as a, useTheme, useBreakpoints} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
ButtonText,
|
||||
useButtonContext,
|
||||
} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {
|
||||
ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
|
||||
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft,
|
||||
} from '#/components/icons/Chevron'
|
||||
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
|
||||
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function SelectReportOptionView({
|
||||
...props
|
||||
}: ReportDialogProps & {
|
||||
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||
onSelectReportOption: (reportOption: ReportOption) => void
|
||||
goBack: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const allReportOptions = useReportOptions()
|
||||
const reportOptions = allReportOptions[props.params.type]
|
||||
|
||||
const i18n = React.useMemo(() => {
|
||||
let title = _(msg`Report this content`)
|
||||
let description = _(msg`Why should this content be reviewed?`)
|
||||
|
||||
if (props.params.type === 'account') {
|
||||
title = _(msg`Report this user`)
|
||||
description = _(msg`Why should this user be reviewed?`)
|
||||
} else if (props.params.type === 'post') {
|
||||
title = _(msg`Report this post`)
|
||||
description = _(msg`Why should this post be reviewed?`)
|
||||
} else if (props.params.type === 'list') {
|
||||
title = _(msg`Report this list`)
|
||||
description = _(msg`Why should this list be reviewed?`)
|
||||
} else if (props.params.type === 'feedgen') {
|
||||
title = _(msg`Report this feed`)
|
||||
description = _(msg`Why should this feed be reviewed?`)
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
}
|
||||
}, [_, props.params.type])
|
||||
|
||||
return (
|
||||
<View style={[a.gap_lg]}>
|
||||
{props.labelers?.length > 1 ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={_(msg`Go back to previous step`)}
|
||||
onPress={props.goBack}>
|
||||
<ButtonIcon icon={ChevronLeft} />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>{i18n.title}</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{i18n.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}>
|
||||
{reportOptions.map(reportOption => {
|
||||
return (
|
||||
<Button
|
||||
key={reportOption.reason}
|
||||
label={_(msg`Create report for ${reportOption.title}`)}
|
||||
onPress={() => props.onSelectReportOption(reportOption)}>
|
||||
<ReportOptionButton
|
||||
title={reportOption.title}
|
||||
description={reportOption.description}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
|
||||
{(props.params.type === 'post' || props.params.type === 'account') && (
|
||||
<View style={[a.pt_md, a.px_md]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.p_md,
|
||||
a.pl_lg,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_900,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.text_inverted,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
<Trans>Need to report a copyright violation?</Trans>
|
||||
</Text>
|
||||
<Link
|
||||
to={DMCA_LINK}
|
||||
label={_(msg`View details for reporting a copyright violation`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>View details</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon position="right" icon={SquareArrowTopRight} />
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportOptionButton({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {hovered, pressed} = useButtonContext()
|
||||
const interacted = hovered || pressed
|
||||
|
||||
const styles = React.useMemo(() => {
|
||||
return {
|
||||
interacted: {
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
},
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.p_md,
|
||||
a.rounded_md,
|
||||
{paddingRight: 70},
|
||||
interacted && styles.interacted,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[a.leading_tight, {maxWidth: 400}]}>{description}</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.justify_center,
|
||||
a.pr_md,
|
||||
{left: 'auto'},
|
||||
]}>
|
||||
<ChevronRight
|
||||
size="md"
|
||||
fill={
|
||||
hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
|
||||
import {atoms as a, useTheme, native} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
import {ReportDialogProps} from './types'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export function SubmitView({
|
||||
params,
|
||||
labelers,
|
||||
selectedLabeler,
|
||||
selectedReportOption,
|
||||
goBack,
|
||||
onSubmitComplete,
|
||||
}: ReportDialogProps & {
|
||||
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||
selectedLabeler: string
|
||||
selectedReportOption: ReportOption
|
||||
goBack: () => void
|
||||
onSubmitComplete: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const [details, setDetails] = React.useState<string>('')
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
||||
selectedLabeler,
|
||||
])
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const submit = React.useCallback(async () => {
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
const $type =
|
||||
params.type === 'account'
|
||||
? 'com.atproto.admin.defs#repoRef'
|
||||
: 'com.atproto.repo.strongRef'
|
||||
const report = {
|
||||
reasonType: selectedReportOption.reason,
|
||||
subject: {
|
||||
$type,
|
||||
...params,
|
||||
},
|
||||
reason: details,
|
||||
}
|
||||
const results = await Promise.all(
|
||||
selectedServices.map(did =>
|
||||
getAgent()
|
||||
.withProxy('atproto_labeler', did)
|
||||
.createModerationReport(report)
|
||||
.then(
|
||||
_ => true,
|
||||
_ => false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
setSubmitting(false)
|
||||
|
||||
if (results.includes(true)) {
|
||||
Toast.show(_(msg`Thank you. Your report has been sent.`))
|
||||
onSubmitComplete()
|
||||
} else {
|
||||
setError(
|
||||
_(
|
||||
msg`There was an issue sending your report. Please check your internet connection.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}, [
|
||||
_,
|
||||
params,
|
||||
details,
|
||||
selectedReportOption,
|
||||
selectedServices,
|
||||
onSubmitComplete,
|
||||
setError,
|
||||
])
|
||||
|
||||
return (
|
||||
<View style={[a.gap_2xl]}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={_(msg`Go back to previous step`)}
|
||||
onPress={goBack}>
|
||||
<ButtonIcon icon={ChevronLeft} />
|
||||
</Button>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.p_md,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.text_md, a.font_bold]}>
|
||||
{selectedReportOption.title}
|
||||
</Text>
|
||||
<Text style={[a.leading_tight, {maxWidth: 400}]}>
|
||||
{selectedReportOption.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Check size="md" style={[a.pr_sm, t.atoms.text_contrast_low]} />
|
||||
</View>
|
||||
|
||||
<View style={[a.gap_md]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Select the moderation service(s) to report to</Trans>
|
||||
</Text>
|
||||
|
||||
<Toggle.Group
|
||||
label="Select mod services"
|
||||
values={selectedServices}
|
||||
onChange={setSelectedServices}>
|
||||
<View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
|
||||
{labelers.map(labeler => {
|
||||
const title = getLabelingServiceTitle({
|
||||
displayName: labeler.creator.displayName,
|
||||
handle: labeler.creator.handle,
|
||||
})
|
||||
return (
|
||||
<Toggle.Item
|
||||
key={labeler.creator.did}
|
||||
name={labeler.creator.did}
|
||||
label={title}>
|
||||
<LabelerToggle title={title} />
|
||||
</Toggle.Item>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
</View>
|
||||
<View style={[a.gap_md]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Optionally provide additional information below:</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.relative, a.w_full]}>
|
||||
<Dialog.Input
|
||||
multiline
|
||||
value={details}
|
||||
onChangeText={setDetails}
|
||||
label="Text field"
|
||||
style={{paddingRight: 60}}
|
||||
numberOfLines={6}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.pr_md,
|
||||
a.pb_sm,
|
||||
{
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
},
|
||||
]}>
|
||||
<CharProgress count={details?.length || 0} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_lg]}>
|
||||
{!selectedServices.length ||
|
||||
(error && (
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{error ? (
|
||||
error
|
||||
) : (
|
||||
<Trans>You must select at least one labeler for a report</Trans>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
<Button
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="negative"
|
||||
label={_(msg`Send report`)}
|
||||
onPress={submit}
|
||||
disabled={!selectedServices.length}>
|
||||
<ButtonText>
|
||||
<Trans>Send report</Trans>
|
||||
</ButtonText>
|
||||
{submitting && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function LabelerToggle({title}: {title: string}) {
|
||||
const t = useTheme()
|
||||
const ctx = Toggle.useItemContext()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_md,
|
||||
a.p_md,
|
||||
a.pr_lg,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
ctx.selected && [t.atoms.bg_contrast_50],
|
||||
]}>
|
||||
<Toggle.Checkbox />
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.z_10,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
native({marginTop: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
ctx.selected && t.atoms.text,
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
import {ReportDialogProps} from './types'
|
||||
import {SelectLabelerView} from './SelectLabelerView'
|
||||
import {SelectReportOptionView} from './SelectReportOptionView'
|
||||
import {SubmitView} from './SubmitView'
|
||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
export function ReportDialog(props: ReportDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<ReportDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDialogInner(props: ReportDialogProps) {
|
||||
const {
|
||||
isLoading: isLabelerLoading,
|
||||
data: labelers,
|
||||
error,
|
||||
} = useMyLabelersQuery()
|
||||
const isLoading = useDelayedLoading(500, isLabelerLoading)
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label="Report Dialog">
|
||||
{isLoading ? (
|
||||
<View style={[a.align_center, {height: 100}]}>
|
||||
<Loader size="xl" />
|
||||
{/* Here to capture focus for a hot sec to prevent flash */}
|
||||
<Pressable accessible={false} />
|
||||
</View>
|
||||
) : error || !labelers ? (
|
||||
<View>
|
||||
<Text style={[a.text_md]}>
|
||||
<Trans>Something went wrong, please try again.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ReportDialogLoaded labelers={labelers} {...props} />
|
||||
)}
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportDialogLoaded(
|
||||
props: ReportDialogProps & {
|
||||
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||
},
|
||||
) {
|
||||
const [selectedLabeler, setSelectedLabeler] = React.useState<
|
||||
string | undefined
|
||||
>(props.labelers.length === 1 ? props.labelers[0].creator.did : undefined)
|
||||
const [selectedReportOption, setSelectedReportOption] = React.useState<
|
||||
ReportOption | undefined
|
||||
>()
|
||||
|
||||
if (selectedReportOption && selectedLabeler) {
|
||||
return (
|
||||
<SubmitView
|
||||
{...props}
|
||||
selectedLabeler={selectedLabeler}
|
||||
selectedReportOption={selectedReportOption}
|
||||
goBack={() => setSelectedReportOption(undefined)}
|
||||
onSubmitComplete={() => props.control.close()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (selectedLabeler) {
|
||||
return (
|
||||
<SelectReportOptionView
|
||||
{...props}
|
||||
goBack={() => setSelectedLabeler(undefined)}
|
||||
onSelectReportOption={setSelectedReportOption}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <SelectLabelerView {...props} onSelectLabeler={setSelectedLabeler} />
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
export type ReportDialogProps = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
params:
|
||||
| {
|
||||
type: 'post' | 'list' | 'feedgen' | 'other'
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
| {
|
||||
type: 'account'
|
||||
did: string
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export function TagMenu({
|
||||
const displayTag = '#' + tag
|
||||
|
||||
const isMuted = Boolean(
|
||||
(preferences?.mutedWords?.find(
|
||||
(preferences?.moderationPrefs.mutedWords?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
) ??
|
||||
optimisticUpsert?.find(
|
||||
@@ -264,7 +264,9 @@ export function TagMenu({
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function TagMenu({
|
||||
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
||||
useRemoveMutedWordMutation()
|
||||
const isMuted = Boolean(
|
||||
(preferences?.mutedWords?.find(
|
||||
(preferences?.moderationPrefs.mutedWords?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
) ??
|
||||
optimisticUpsert?.find(
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React from 'react'
|
||||
import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
|
||||
import {
|
||||
Text as RNText,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
TextProps as RNTextProps,
|
||||
} from 'react-native'
|
||||
import {UITextView} from 'react-native-ui-text-view'
|
||||
|
||||
import {useTheme, atoms, web, flatten} from '#/alf'
|
||||
@@ -34,7 +39,7 @@ export function leading<
|
||||
* If the `lineHeight` value is > 2, we assume it's an absolute value and
|
||||
* returns it as-is.
|
||||
*/
|
||||
function normalizeTextStyles(styles: TextStyle[]) {
|
||||
export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
|
||||
const s = flatten(styles)
|
||||
// should always be defined on these components
|
||||
const fontSize = s.fontSize || atoms.text_md.fontSize
|
||||
|
||||
@@ -1,48 +1,64 @@
|
||||
import React from 'react'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '../Typography'
|
||||
import {DateInput} from '#/view/com/util/forms/DateInput'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
UsePreferencesQueryResponse,
|
||||
} from '#/state/queries/preferences'
|
||||
import {Button, ButtonText} from '../Button'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {ActivityIndicator, View} from 'react-native'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {Loader} from '#/components/Loader'
|
||||
|
||||
export function BirthDateSettingsDialog({
|
||||
control,
|
||||
preferences,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
preferences: UsePreferencesQueryResponse | undefined
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {isPending, isError, error, mutateAsync} =
|
||||
usePreferencesSetBirthDateMutation()
|
||||
const {isLoading, error, data: preferences} = usePreferencesQuery()
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner label={_(msg`My Birthday`)}>
|
||||
{preferences && !isPending ? (
|
||||
<BirthdayInner
|
||||
control={control}
|
||||
preferences={preferences}
|
||||
isError={isError}
|
||||
error={error}
|
||||
setBirthDate={mutateAsync}
|
||||
<View style={[a.gap_sm, a.pb_lg]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>My Birthday</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>This information is not shared with other users.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isLoading ? (
|
||||
<Loader size="xl" />
|
||||
) : error || !preferences ? (
|
||||
<ErrorMessage
|
||||
message={
|
||||
error?.toString() ||
|
||||
_(
|
||||
msg`We were unable to load your birth date preferences. Please try again.`,
|
||||
)
|
||||
}
|
||||
style={[a.rounded_sm]}
|
||||
/>
|
||||
) : (
|
||||
<ActivityIndicator size="large" style={a.my_5xl} />
|
||||
<BirthdayInner control={control} preferences={preferences} />
|
||||
)}
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
@@ -51,20 +67,18 @@ export function BirthDateSettingsDialog({
|
||||
function BirthdayInner({
|
||||
control,
|
||||
preferences,
|
||||
isError,
|
||||
error,
|
||||
setBirthDate,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
preferences: UsePreferencesQueryResponse
|
||||
isError: boolean
|
||||
error: unknown
|
||||
setBirthDate: (args: {birthDate: Date}) => Promise<unknown>
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [date, setDate] = React.useState(preferences.birthDate || new Date())
|
||||
const t = useTheme()
|
||||
|
||||
const {
|
||||
isPending,
|
||||
isError,
|
||||
error,
|
||||
mutateAsync: setBirthDate,
|
||||
} = usePreferencesSetBirthDateMutation()
|
||||
const hasChanged = date !== preferences.birthDate
|
||||
|
||||
const onSave = React.useCallback(async () => {
|
||||
@@ -74,21 +88,13 @@ function BirthdayInner({
|
||||
await setBirthDate({birthDate: date})
|
||||
}
|
||||
control.close()
|
||||
} catch (e) {
|
||||
logger.error(`setBirthDate failed`, {message: e})
|
||||
} catch (e: any) {
|
||||
logger.error(`setBirthDate failed`, {message: e.message})
|
||||
}
|
||||
}, [date, setBirthDate, control, hasChanged])
|
||||
|
||||
return (
|
||||
<View style={a.gap_lg} testID="birthDateSettingsDialog">
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>My Birthday</Trans>
|
||||
</Text>
|
||||
<Text style={t.atoms.text_contrast_medium}>
|
||||
<Trans>This information is not shared with other users.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={isIOS && [a.w_full, a.align_center]}>
|
||||
<DateInput
|
||||
handleAsUTC
|
||||
@@ -103,6 +109,7 @@ function BirthdayInner({
|
||||
accessibilityLabelledBy="birthDate"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{isError ? (
|
||||
<ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} />
|
||||
) : undefined}
|
||||
@@ -110,13 +117,14 @@ function BirthdayInner({
|
||||
<View style={isWeb && [a.flex_row, a.justify_end]}>
|
||||
<Button
|
||||
label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)}
|
||||
size={isWeb ? 'small' : 'medium'}
|
||||
size="medium"
|
||||
onPress={onSave}
|
||||
variant="solid"
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
{hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>}
|
||||
</ButtonText>
|
||||
{isPending && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function useGlobalDialogsControlContext() {
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const mutedWordsDialogControl = Dialog.useDialogControl()
|
||||
const ctx = React.useMemo(
|
||||
const ctx = React.useMemo<ControlsContext>(
|
||||
() => ({mutedWordsDialogControl}),
|
||||
[mutedWordsDialogControl],
|
||||
)
|
||||
|
||||
@@ -233,8 +233,8 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : preferences.mutedWords.length ? (
|
||||
[...preferences.mutedWords]
|
||||
) : preferences.moderationPrefs.mutedWords.length ? (
|
||||
[...preferences.moderationPrefs.mutedWords]
|
||||
.reverse()
|
||||
.map((word, i) => (
|
||||
<MutedWordRow
|
||||
|
||||
@@ -2,7 +2,14 @@ import React from 'react'
|
||||
import {Pressable, View, ViewStyle} from 'react-native'
|
||||
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
|
||||
import {
|
||||
useTheme,
|
||||
atoms as a,
|
||||
native,
|
||||
flatten,
|
||||
ViewStyleProp,
|
||||
TextStyleProp,
|
||||
} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
|
||||
@@ -220,20 +227,17 @@ export function Item({
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
focused ? web({outline: 'none'}) : {},
|
||||
flatten(style),
|
||||
]}>
|
||||
style={[a.flex_row, a.align_center, a.gap_sm, flatten(style)]}>
|
||||
{typeof children === 'function' ? children(state) : children}
|
||||
</Pressable>
|
||||
</ItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Label({children}: React.PropsWithChildren<{}>) {
|
||||
export function Label({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<TextStyleProp>) {
|
||||
const t = useTheme()
|
||||
const {disabled} = useItemContext()
|
||||
return (
|
||||
@@ -242,11 +246,14 @@ export function Label({children}: React.PropsWithChildren<{}>) {
|
||||
a.font_bold,
|
||||
{
|
||||
userSelect: 'none',
|
||||
color: disabled ? t.palette.contrast_400 : t.palette.contrast_600,
|
||||
color: disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_high.color,
|
||||
},
|
||||
native({
|
||||
paddingTop: 3,
|
||||
}),
|
||||
flatten(style),
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
@@ -257,7 +264,6 @@ export function Label({children}: React.PropsWithChildren<{}>) {
|
||||
export function createSharedToggleStyles({
|
||||
theme: t,
|
||||
hovered,
|
||||
focused,
|
||||
selected,
|
||||
disabled,
|
||||
isInvalid,
|
||||
@@ -280,7 +286,7 @@ export function createSharedToggleStyles({
|
||||
borderColor: t.palette.primary_500,
|
||||
})
|
||||
|
||||
if (hovered || focused) {
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
|
||||
@@ -289,7 +295,7 @@ export function createSharedToggleStyles({
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (hovered || focused) {
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
|
||||
@@ -306,7 +312,7 @@ export function createSharedToggleStyles({
|
||||
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
|
||||
})
|
||||
|
||||
if (hovered || focused) {
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
@@ -353,7 +359,7 @@ export function Checkbox() {
|
||||
width: 20,
|
||||
},
|
||||
baseStyles,
|
||||
hovered || focused ? baseHoverStyles : {},
|
||||
hovered ? baseHoverStyles : {},
|
||||
]}>
|
||||
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
|
||||
</View>
|
||||
@@ -385,7 +391,7 @@ export function Switch() {
|
||||
width: 30,
|
||||
},
|
||||
baseStyles,
|
||||
hovered || focused ? baseHoverStyles : {},
|
||||
hovered ? baseHoverStyles : {},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
@@ -437,7 +443,7 @@ export function Radio() {
|
||||
width: 20,
|
||||
},
|
||||
baseStyles,
|
||||
hovered || focused ? baseHoverStyles : {},
|
||||
hovered ? baseHoverStyles : {},
|
||||
]}>
|
||||
{selected ? (
|
||||
<View
|
||||
|
||||
@@ -8,7 +8,9 @@ import * as Toggle from '#/components/forms/Toggle'
|
||||
|
||||
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
|
||||
AccessibilityProps &
|
||||
React.PropsWithChildren<{testID?: string}>
|
||||
React.PropsWithChildren<{
|
||||
testID?: string
|
||||
}>
|
||||
|
||||
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
|
||||
multiple?: boolean
|
||||
@@ -101,12 +103,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
|
||||
native({
|
||||
paddingBottom: 10,
|
||||
}),
|
||||
a.px_sm,
|
||||
a.px_md,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
baseStyles,
|
||||
activeStyles,
|
||||
(state.hovered || state.focused || state.pressed) && hoverStyles,
|
||||
(state.hovered || state.pressed) && hoverStyles,
|
||||
]}>
|
||||
{typeof children === 'string' ? (
|
||||
<Text
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
|
||||
export function useDelayedLoading(delay: number, initialState: boolean = true) {
|
||||
const [isLoading, setIsLoading] = React.useState(initialState)
|
||||
|
||||
React.useEffect(() => {
|
||||
let timeout: NodeJS.Timeout
|
||||
// on initial load, show a loading spinner for a hot sec to prevent flash
|
||||
if (isLoading) timeout = setTimeout(() => setIsLoading(false), delay)
|
||||
|
||||
return () => timeout && clearTimeout(timeout)
|
||||
}, [isLoading, delay])
|
||||
|
||||
return isLoading
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const ArrowTriangleBottom_Stroke2_Corner1_Rounded = createSinglePathSVG({
|
||||
path: 'M4.213 6.886c-.673-1.35.334-2.889 1.806-2.889H17.98c1.472 0 2.479 1.539 1.806 2.89l-5.982 11.997c-.74 1.484-2.87 1.484-3.61 0L4.213 6.886Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Bars3_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 5a1 1 0 0 0 0 2h18a1 1 0 1 0 0-2H3Zm-1 7a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Zm0 6a1 1 0 0 1 1-1h18a1 1 0 1 1 0 2H3a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
@@ -7,3 +7,11 @@ export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
|
||||
export const ChevronTop_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 6a1 1 0 0 1 .707.293l8 8a1 1 0 0 1-1.414 1.414L12 8.414l-7.293 7.293a1 1 0 0 1-1.414-1.414l8-8A1 1 0 0 1 12 6Z',
|
||||
})
|
||||
|
||||
export const ChevronBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3.293 8.293a1 1 0 0 1 1.414 0L12 15.586l7.293-7.293a1 1 0 1 1 1.414 1.414l-8 8a1 1 0 0 1-1.414 0l-8-8a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const CircleBanSign_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a8 8 0 0 0-6.32 12.906L16.906 5.68A7.962 7.962 0 0 0 12 4Zm6.32 3.094L7.094 18.32A8 8 0 0 0 18.32 7.094ZM2 12C2 6.477 6.477 2 12 2a9.972 9.972 0 0 1 7.071 2.929A9.972 9.972 0 0 1 22 12c0 5.523-4.477 10-10 10a9.972 9.972 0 0 1-7.071-2.929A9.972 9.972 0 0 1 2 12Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
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',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const RaisingHande4Finger_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M10.25 4a.75.75 0 0 0-.75.75V11a1 1 0 1 1-2 0V6.75a.75.75 0 0 0-1.5 0V14a6 6 0 0 0 12 0V9a2 2 0 0 0-2 2v1.5a1 1 0 0 1-.684.949l-.628.21A2.469 2.469 0 0 0 13 16a1 1 0 1 1-2 0 4.469 4.469 0 0 1 3-4.22V11c0-.703.181-1.364.5-1.938V5.75a.75.75 0 0 0-1.5 0V9a1 1 0 1 1-2 0V4.75a.75.75 0 0 0-.75-.75Zm2.316-.733A2.75 2.75 0 0 1 16.5 5.75v1.54c.463-.187.97-.29 1.5-.29h1a1 1 0 0 1 1 1v6a8 8 0 1 1-16 0V6.75a2.75 2.75 0 0 1 3.571-2.625 2.751 2.751 0 0 1 4.995-.858Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Shield_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11.675 2.054a1 1 0 0 1 .65 0l8 2.75A1 1 0 0 1 21 5.75v6.162c0 2.807-1.149 4.83-2.813 6.405-1.572 1.488-3.632 2.6-5.555 3.636l-.157.085a1 1 0 0 1-.95 0l-.157-.085c-1.923-1.037-3.983-2.148-5.556-3.636C4.15 16.742 3 14.719 3 11.912V5.75a1 1 0 0 1 .675-.946l8-2.75ZM5 6.464v5.448c0 2.166.851 3.687 2.188 4.952 1.276 1.209 2.964 2.158 4.812 3.157 1.848-1 3.536-1.948 4.813-3.157C18.148 15.6 19 14.078 19 11.912V6.464l-7-2.407-7 2.407Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const SquareArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-7.293 7.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM3 6a1 1 0 0 1 1-1h5a1 1 0 0 1 0 2H5v12h12v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const SquareBehindSquare4_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8 8V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-5v5a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h5Zm1 8a1 1 0 0 1-1-1v-5H4v10h10v-4H9Z',
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {isJustAMute} from '#/lib/moderation'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
|
||||
import {atoms as a, useTheme, useBreakpoints, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
|
||||
export function ContentHider({
|
||||
testID,
|
||||
modui,
|
||||
ignoreMute,
|
||||
style,
|
||||
childContainerStyle,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
testID?: string
|
||||
modui: ModerationUI | undefined
|
||||
ignoreMute?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
childContainerStyle?: StyleProp<ViewStyle>
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const control = useModerationDetailsDialogControl()
|
||||
|
||||
const blur = modui?.blurs[0]
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
if (!blur || (ignoreMute && isJustAMute(modui))) {
|
||||
return (
|
||||
<View testID={testID} style={[styles.outer, style]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID={testID} style={[a.overflow_hidden, style]}>
|
||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||
|
||||
<Button
|
||||
onPress={() => {
|
||||
if (!modui.noOverride) {
|
||||
setOverride(v => !v)
|
||||
} else {
|
||||
control.open()
|
||||
}
|
||||
}}
|
||||
label={desc.name}
|
||||
accessibilityHint={
|
||||
modui.noOverride
|
||||
? _(msg`Learn more about the moderation applied to this content.`)
|
||||
: override
|
||||
? _(msg`Hide the content`)
|
||||
: _(msg`Show the content`)
|
||||
}>
|
||||
{state => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.w_full,
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.gap_xs,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
gtMobile && [a.gap_sm, a.py_lg, a.mt_xs, a.px_xl],
|
||||
(state.hovered || state.pressed) && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<desc.icon
|
||||
size="md"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
style={{marginLeft: -2}}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_left,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
gtMobile && [a.font_semibold],
|
||||
t.atoms.text_contrast_medium,
|
||||
web({
|
||||
marginBottom: 1,
|
||||
}),
|
||||
]}>
|
||||
{desc.name}
|
||||
</Text>
|
||||
{!modui.noOverride && (
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
gtMobile && [a.font_semibold],
|
||||
t.atoms.text_contrast_high,
|
||||
web({
|
||||
marginBottom: 1,
|
||||
}),
|
||||
]}>
|
||||
{override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{desc.source && blur.type === 'label' && !override && (
|
||||
<Button
|
||||
onPress={() => {
|
||||
control.open()
|
||||
}}
|
||||
label={_(
|
||||
msg`Learn more about the moderation applied to this content.`,
|
||||
)}
|
||||
style={[a.pt_sm]}>
|
||||
{state => (
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.font_normal,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_left,
|
||||
]}>
|
||||
{desc.sourceType === 'user' ? (
|
||||
<Trans>Labeled by the author.</Trans>
|
||||
) : (
|
||||
<Trans>Labeled by {sanitizeDisplayName(desc.source!)}.</Trans>
|
||||
)}{' '}
|
||||
<Text
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
a.text_sm,
|
||||
state.hovered && [web({textDecoration: 'underline'})],
|
||||
]}>
|
||||
<Trans>Learn more.</Trans>
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{override && <View style={childContainerStyle}>{children}</View>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cover: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
paddingVertical: 14,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 18,
|
||||
},
|
||||
showBtn: {
|
||||
marginLeft: 'auto',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
|
||||
export function GlobalModerationLabelPref({
|
||||
labelValueDefinition,
|
||||
disabled,
|
||||
}: {
|
||||
labelValueDefinition: InterpretedLabelValueDefinition
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {identifier} = labelValueDefinition
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const savedPref = preferences?.moderationPrefs.labels[identifier]
|
||||
const pref = variables?.visibility ?? savedPref ?? 'warn'
|
||||
|
||||
const allLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings =
|
||||
labelValueDefinition.identifier in allLabelStrings
|
||||
? allLabelStrings[labelValueDefinition.identifier]
|
||||
: {
|
||||
name: labelValueDefinition.identifier,
|
||||
description: `Labeled "${labelValueDefinition.identifier}"`,
|
||||
}
|
||||
|
||||
const labelOptions = {
|
||||
hide: _(msg`Hide`),
|
||||
warn: _(msg`Warn`),
|
||||
ignore: _(msg`Show`),
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
a.py_md,
|
||||
a.pl_lg,
|
||||
a.pr_md,
|
||||
a.align_center,
|
||||
]}>
|
||||
<View style={[a.gap_xs, a.flex_1]}>
|
||||
<Text style={[a.font_bold]}>{labelStrings.name}</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{labelStrings.description}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.justify_center, {minHeight: 35}]}>
|
||||
{!disabled && (
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||
)}
|
||||
values={[pref]}
|
||||
onChange={newPref =>
|
||||
mutate({
|
||||
label: identifier,
|
||||
visibility: newPref[0] as LabelPreference,
|
||||
labelerDid: undefined,
|
||||
})
|
||||
}>
|
||||
<ToggleButton.Button name="ignore" label={labelOptions.ignore}>
|
||||
{labelOptions.ignore}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="warn" label={labelOptions.warn}>
|
||||
{labelOptions.warn}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="hide" label={labelOptions.hide}>
|
||||
{labelOptions.hide}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from '#/state/session'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText, ButtonIcon, ButtonSize} from '#/components/Button'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {
|
||||
LabelsOnMeDialog,
|
||||
useLabelsOnMeDialogControl,
|
||||
} from '#/components/moderation/LabelsOnMeDialog'
|
||||
|
||||
export function LabelsOnMe({
|
||||
details,
|
||||
labels,
|
||||
size,
|
||||
style,
|
||||
}: {
|
||||
details: {did: string} | {uri: string; cid: string}
|
||||
labels: ComAtprotoLabelDefs.Label[] | undefined
|
||||
size?: ButtonSize
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const isAccount = 'did' in details
|
||||
const control = useLabelsOnMeDialogControl()
|
||||
|
||||
if (!labels || !currentAccount) {
|
||||
return null
|
||||
}
|
||||
labels = labels.filter(
|
||||
l => !l.val.startsWith('!') && l.src !== currentAccount.did,
|
||||
)
|
||||
if (!labels.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const labelTarget = isAccount ? _(msg`account`) : _(msg`content`)
|
||||
return (
|
||||
<View style={[a.flex_row, style]}>
|
||||
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size={size || 'small'}
|
||||
label={_(msg`View information about these labels`)}
|
||||
onPress={() => {
|
||||
control.open()
|
||||
}}>
|
||||
<ButtonIcon position="left" icon={CircleInfo} />
|
||||
<ButtonText style={[a.leading_snug]}>
|
||||
{labels.length}{' '}
|
||||
{labels.length === 1 ? (
|
||||
<Trans>label has been placed on this {labelTarget}</Trans>
|
||||
) : (
|
||||
<Trans>labels have been placed on this {labelTarget}</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function LabelsOnMyPost({
|
||||
post,
|
||||
style,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
if (post.author.did !== currentAccount?.did) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api'
|
||||
|
||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {Divider} from '../Divider'
|
||||
|
||||
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
||||
|
||||
type Subject =
|
||||
| {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
| {
|
||||
did: string
|
||||
}
|
||||
|
||||
export interface LabelsOnMeDialogProps {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
subject: Subject
|
||||
labels: ComAtprotoLabelDefs.Label[]
|
||||
}
|
||||
|
||||
export function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||
const {_} = useLingui()
|
||||
const [appealingLabel, setAppealingLabel] = React.useState<
|
||||
ComAtprotoLabelDefs.Label | undefined
|
||||
>(undefined)
|
||||
const {subject, labels} = props
|
||||
const isAccount = 'did' in subject
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={
|
||||
isAccount
|
||||
? _(msg`The following labels were applied to your account.`)
|
||||
: _(msg`The following labels were applied to your content.`)
|
||||
}>
|
||||
{appealingLabel ? (
|
||||
<AppealForm
|
||||
label={appealingLabel}
|
||||
subject={subject}
|
||||
control={props.control}
|
||||
onPressBack={() => setAppealingLabel(undefined)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
|
||||
{isAccount ? (
|
||||
<Trans>Labels on your account</Trans>
|
||||
) : (
|
||||
<Trans>Labels on your content</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
You may appeal these labels if you feel they were placed in error.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.py_lg, a.gap_md]}>
|
||||
{labels.map(label => (
|
||||
<Label
|
||||
key={`${label.val}-${label.src}`}
|
||||
label={label}
|
||||
control={props.control}
|
||||
onPressAppeal={label => setAppealingLabel(label)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<LabelsOnMeDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Label({
|
||||
label,
|
||||
control,
|
||||
onPressAppeal,
|
||||
}: {
|
||||
label: ComAtprotoLabelDefs.Label
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
onPressAppeal: (label: ComAtprotoLabelDefs.Label) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {labeler, strings} = useLabelInfo(label)
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<View style={[a.p_md, a.gap_sm, a.flex_row]}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.font_bold, a.text_md]}>{strings.name}</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{strings.description}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label={_(msg`Appeal`)}
|
||||
onPress={() => onPressAppeal(label)}>
|
||||
<ButtonText>
|
||||
<Trans>Appeal</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Source:</Trans>{' '}
|
||||
<InlineLink
|
||||
to={makeProfileLink(
|
||||
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||
)}
|
||||
onPress={() => control.close()}>
|
||||
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
|
||||
</InlineLink>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AppealForm({
|
||||
label,
|
||||
subject,
|
||||
control,
|
||||
onPressBack,
|
||||
}: {
|
||||
label: ComAtprotoLabelDefs.Label
|
||||
subject: Subject
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
onPressBack: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {labeler, strings} = useLabelInfo(label)
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [details, setDetails] = React.useState('')
|
||||
const isAccountReport = 'did' in subject
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
const $type = !isAccountReport
|
||||
? 'com.atproto.repo.strongRef'
|
||||
: 'com.atproto.admin.defs#repoRef'
|
||||
await getAgent()
|
||||
.withProxy('atproto_labeler', label.src)
|
||||
.createModerationReport({
|
||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||
subject: {
|
||||
$type,
|
||||
...subject,
|
||||
},
|
||||
reason: details,
|
||||
})
|
||||
Toast.show(_(msg`Appeal submitted.`))
|
||||
} finally {
|
||||
control.close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={[a.text_2xl, a.font_bold, a.pb_xs, a.leading_tight]}>
|
||||
<Trans>Appeal "{strings.name}" label</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
This appeal will be sent to{' '}
|
||||
<InlineLink
|
||||
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}
|
||||
</InlineLink>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.my_md]}>
|
||||
<Dialog.Input
|
||||
label={_(msg`Text input field`)}
|
||||
placeholder={_(
|
||||
msg`Please explain why you think this label was incorrectly applied by ${
|
||||
labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src
|
||||
}`,
|
||||
)}
|
||||
value={details}
|
||||
onChangeText={setDetails}
|
||||
autoFocus={true}
|
||||
numberOfLines={3}
|
||||
multiline
|
||||
maxLength={300}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={
|
||||
gtMobile
|
||||
? [a.flex_row, a.justify_between]
|
||||
: [{flexDirection: 'column-reverse'}, a.gap_sm]
|
||||
}>
|
||||
<Button
|
||||
testID="backBtn"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
onPress={onPressBack}
|
||||
label={_(msg`Back`)}>
|
||||
{_(msg`Back`)}
|
||||
</Button>
|
||||
<Button
|
||||
testID="submitBtn"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="medium"
|
||||
onPress={onSubmit}
|
||||
label={_(msg`Submit`)}>
|
||||
{_(msg`Submit`)}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {ModerationCause} from '@atproto/api'
|
||||
|
||||
import {listUriToHref} from '#/lib/strings/url-helpers'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {Divider} from '#/components/Divider'
|
||||
|
||||
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
|
||||
|
||||
export interface ModerationDetailsDialogProps {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
modcause: ModerationCause
|
||||
}
|
||||
|
||||
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
<ModerationDetailsDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function ModerationDetailsDialogInner({
|
||||
modcause,
|
||||
control,
|
||||
}: ModerationDetailsDialogProps & {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const desc = useModerationCauseDescription(modcause)
|
||||
|
||||
let name
|
||||
let description
|
||||
if (!modcause) {
|
||||
name = _(msg`Content Warning`)
|
||||
description = _(
|
||||
msg`Moderator has chosen to set a general warning on the content.`,
|
||||
)
|
||||
} else if (modcause.type === 'blocking') {
|
||||
if (modcause.source.type === 'list') {
|
||||
const list = modcause.source.list
|
||||
name = _(msg`User Blocked by List`)
|
||||
description = (
|
||||
<Trans>
|
||||
This user is included in the{' '}
|
||||
<InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}>
|
||||
{list.name}
|
||||
</InlineLink>{' '}
|
||||
list which you have blocked.
|
||||
</Trans>
|
||||
)
|
||||
} else {
|
||||
name = _(msg`User Blocked`)
|
||||
description = _(
|
||||
msg`You have blocked this user. You cannot view their content.`,
|
||||
)
|
||||
}
|
||||
} else if (modcause.type === 'blocked-by') {
|
||||
name = _(msg`User Blocks You`)
|
||||
description = _(
|
||||
msg`This user has blocked you. You cannot view their content.`,
|
||||
)
|
||||
} else if (modcause.type === 'block-other') {
|
||||
name = _(msg`Content Not Available`)
|
||||
description = _(
|
||||
msg`This content is not available because one of the users involved has blocked the other.`,
|
||||
)
|
||||
} else if (modcause.type === 'muted') {
|
||||
if (modcause.source.type === 'list') {
|
||||
const list = modcause.source.list
|
||||
name = _(msg`Account Muted by List`)
|
||||
description = (
|
||||
<Trans>
|
||||
This user is included in the{' '}
|
||||
<InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}>
|
||||
{list.name}
|
||||
</InlineLink>{' '}
|
||||
list which you have muted.
|
||||
</Trans>
|
||||
)
|
||||
} else {
|
||||
name = _(msg`Account Muted`)
|
||||
description = _(msg`You have muted this account.`)
|
||||
}
|
||||
} else if (modcause.type === 'mute-word') {
|
||||
name = _(msg`Post Hidden by Muted Word`)
|
||||
description = _(msg`You've chosen to hide a word or tag within this post.`)
|
||||
} else if (modcause.type === 'hidden') {
|
||||
name = _(msg`Post Hidden by You`)
|
||||
description = _(msg`You have hidden this post.`)
|
||||
} else if (modcause.type === 'label') {
|
||||
name = desc.name
|
||||
description = desc.description
|
||||
} else {
|
||||
// should never happen
|
||||
name = ''
|
||||
description = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Moderation details`)}>
|
||||
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}>
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{modcause.type === 'label' && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
|
||||
<Trans>
|
||||
This label was applied by{' '}
|
||||
{modcause.source.type === 'user' ? (
|
||||
<Trans>the author</Trans>
|
||||
) : (
|
||||
<InlineLink
|
||||
to={makeProfileLink({did: modcause.label.src, handle: ''})}
|
||||
onPress={() => control.close()}
|
||||
style={a.text_md}>
|
||||
{desc.source}
|
||||
</InlineLink>
|
||||
)}
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isNative && <View style={{height: 40}} />}
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {getLabelStrings} from '#/lib/moderation/useLabelInfo'
|
||||
|
||||
import {useTheme, atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
|
||||
export function ModerationLabelPref({
|
||||
labelValueDefinition,
|
||||
labelerDid,
|
||||
disabled,
|
||||
}: {
|
||||
labelValueDefinition: InterpretedLabelValueDefinition
|
||||
labelerDid: string | undefined
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_, i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
const isGlobalLabel = !labelValueDefinition.definedBy
|
||||
const {identifier} = labelValueDefinition
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const savedPref =
|
||||
labelerDid && !isGlobalLabel
|
||||
? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid)
|
||||
?.labels[identifier]
|
||||
: preferences?.moderationPrefs.labels[identifier]
|
||||
const pref =
|
||||
variables?.visibility ??
|
||||
savedPref ??
|
||||
labelValueDefinition.defaultSetting ??
|
||||
'warn'
|
||||
|
||||
// does the 'warn' setting make sense for this label?
|
||||
const canWarn = !(
|
||||
labelValueDefinition.blurs === 'none' &&
|
||||
labelValueDefinition.severity === 'none'
|
||||
)
|
||||
// is this label adult only?
|
||||
const adultOnly = labelValueDefinition.flags.includes('adult')
|
||||
// is this label disabled because it's adult only?
|
||||
const adultDisabled =
|
||||
adultOnly && !preferences?.moderationPrefs.adultContentEnabled
|
||||
// are there any reasons we cant configure this label here?
|
||||
const cantConfigure = isGlobalLabel || adultDisabled
|
||||
const showConfig = !disabled && (gtPhone || !cantConfigure)
|
||||
|
||||
// adjust the pref based on whether warn is available
|
||||
let prefAdjusted = pref
|
||||
if (adultDisabled) {
|
||||
prefAdjusted = 'hide'
|
||||
} else if (!canWarn && pref === 'warn') {
|
||||
prefAdjusted = 'ignore'
|
||||
}
|
||||
|
||||
// grab localized descriptions of the label and its settings
|
||||
const currentPrefLabel = useLabelBehaviorDescription(
|
||||
labelValueDefinition,
|
||||
prefAdjusted,
|
||||
)
|
||||
const hideLabel = useLabelBehaviorDescription(labelValueDefinition, 'hide')
|
||||
const warnLabel = useLabelBehaviorDescription(labelValueDefinition, 'warn')
|
||||
const ignoreLabel = useLabelBehaviorDescription(
|
||||
labelValueDefinition,
|
||||
'ignore',
|
||||
)
|
||||
const globalLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings = getLabelStrings(
|
||||
i18n.locale,
|
||||
globalLabelStrings,
|
||||
labelValueDefinition,
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.justify_between,
|
||||
a.flex_wrap,
|
||||
]}>
|
||||
<View style={[a.gap_xs, a.flex_1]}>
|
||||
<Text style={[a.font_bold, gtPhone ? a.text_sm : a.text_md]}>
|
||||
{labelStrings.name}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{labelStrings.description}
|
||||
</Text>
|
||||
|
||||
{cantConfigure && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}>
|
||||
<CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} />
|
||||
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}>
|
||||
{adultDisabled ? (
|
||||
<Trans>Adult content is disabled.</Trans>
|
||||
) : isGlobalLabel ? (
|
||||
<Trans>
|
||||
Configured in{' '}
|
||||
<InlineLink to="/moderation" style={a.text_sm}>
|
||||
moderation settings
|
||||
</InlineLink>
|
||||
.
|
||||
</Trans>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{showConfig && (
|
||||
<View style={[gtPhone ? undefined : a.w_full]}>
|
||||
{cantConfigure ? (
|
||||
<View
|
||||
style={[
|
||||
{minHeight: 35},
|
||||
a.px_md,
|
||||
a.py_md,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_low]}>
|
||||
{currentPrefLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[{minHeight: 35}]}>
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||
)}
|
||||
values={[prefAdjusted]}
|
||||
onChange={newPref =>
|
||||
mutate({
|
||||
label: identifier,
|
||||
visibility: newPref[0] as LabelPreference,
|
||||
labelerDid,
|
||||
})
|
||||
}>
|
||||
<ToggleButton.Button name="ignore" label={ignoreLabel}>
|
||||
{ignoreLabel}
|
||||
</ToggleButton.Button>
|
||||
{canWarn && (
|
||||
<ToggleButton.Button name="warn" label={warnLabel}>
|
||||
{warnLabel}
|
||||
</ToggleButton.Button>
|
||||
)}
|
||||
<ToggleButton.Button name="hide" label={hideLabel}>
|
||||
{hideLabel}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationUI, ModerationCause} from '@atproto/api'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
|
||||
export function PostAlerts({
|
||||
modui,
|
||||
style,
|
||||
}: {
|
||||
modui: ModerationUI
|
||||
includeMute?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
if (!modui.alert && !modui.inform) {
|
||||
return null
|
||||
}
|
||||
|
||||
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} />
|
||||
))}
|
||||
{modui.informs.map(cause => (
|
||||
<PostLabel key={getModerationCauseKey(cause)} cause={cause} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function PostLabel({cause}: {cause: ModerationCause}) {
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const desc = useModerationCauseDescription(cause)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={desc.name}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
shape="default"
|
||||
onPress={() => {
|
||||
control.open()
|
||||
}}
|
||||
style={[a.px_sm, a.py_xs, a.gap_xs]}>
|
||||
<ButtonIcon icon={desc.icon} position="left" />
|
||||
<ButtonText style={[a.text_left, a.leading_snug]}>
|
||||
{desc.name}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+40
-53
@@ -1,45 +1,50 @@
|
||||
import React, {ComponentProps} from 'react'
|
||||
import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Link} from '../Link'
|
||||
import {Text} from '../text/Text'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {describeModerationCause} from 'lib/moderation'
|
||||
import {ShieldExclamation} from 'lib/icons'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {addStyle} from 'lib/styles'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
|
||||
interface Props extends ComponentProps<typeof Link> {
|
||||
iconSize: number
|
||||
iconStyles: StyleProp<ViewStyle>
|
||||
moderation: ModerationUI
|
||||
modui: ModerationUI
|
||||
}
|
||||
|
||||
export function PostHider({
|
||||
testID,
|
||||
href,
|
||||
moderation,
|
||||
modui,
|
||||
style,
|
||||
children,
|
||||
iconSize,
|
||||
iconStyles,
|
||||
...props
|
||||
}: Props) {
|
||||
const pal = usePalette('default')
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const {openModal} = useModalControls()
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const blur = modui.blurs[0]
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
if (!moderation.blur) {
|
||||
if (!blur) {
|
||||
return (
|
||||
<Link
|
||||
testID={testID}
|
||||
style={style}
|
||||
href={href}
|
||||
noFeedback
|
||||
accessible={false}
|
||||
{...props}>
|
||||
{children}
|
||||
@@ -47,12 +52,10 @@ export function PostHider({
|
||||
)
|
||||
}
|
||||
|
||||
const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '')
|
||||
const desc = describeModerationCause(moderation.cause, 'content')
|
||||
return !override ? (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (!moderation.noOverride) {
|
||||
if (!modui.noOverride) {
|
||||
setOverride(v => !v)
|
||||
}
|
||||
}}
|
||||
@@ -62,49 +65,45 @@ export function PostHider({
|
||||
}
|
||||
accessibilityLabel=""
|
||||
style={[
|
||||
styles.description,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.py_md,
|
||||
{
|
||||
paddingLeft: 6,
|
||||
paddingRight: 18,
|
||||
},
|
||||
override ? {paddingBottom: 0} : undefined,
|
||||
pal.view,
|
||||
t.atoms.bg,
|
||||
]}>
|
||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
openModal({
|
||||
name: 'moderation-details',
|
||||
context: 'content',
|
||||
moderation,
|
||||
})
|
||||
control.open()
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Learn more about this warning`)}
|
||||
accessibilityHint="">
|
||||
<View
|
||||
style={[
|
||||
pal.viewLight,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
borderRadius: iconSize,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconStyles,
|
||||
]}>
|
||||
{isMute ? (
|
||||
<FontAwesomeIcon
|
||||
icon={['far', 'eye-slash']}
|
||||
size={14}
|
||||
color={pal.colors.textLight}
|
||||
/>
|
||||
) : (
|
||||
<ShieldExclamation size={14} style={pal.textLight} />
|
||||
)}
|
||||
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
</Pressable>
|
||||
<Text type="sm" style={[{flex: 1}, pal.textLight]} numberOfLines={1}>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.flex_1]} numberOfLines={1}>
|
||||
{desc.name}
|
||||
</Text>
|
||||
{!moderation.noOverride && (
|
||||
<Text type="sm" style={[styles.showBtn, pal.link]}>
|
||||
{!modui.noOverride && (
|
||||
<Text style={[{color: t.palette.primary_500}]}>
|
||||
{override ? <Trans>Hide</Trans> : <Trans>Show</Trans>}
|
||||
</Text>
|
||||
)}
|
||||
@@ -114,26 +113,14 @@ export function PostHider({
|
||||
testID={testID}
|
||||
style={addStyle(style, styles.child)}
|
||||
href={href}
|
||||
noFeedback>
|
||||
accessible={false}
|
||||
{...props}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
description: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingVertical: 10,
|
||||
paddingLeft: 6,
|
||||
paddingRight: 18,
|
||||
marginTop: 1,
|
||||
},
|
||||
showBtn: {
|
||||
marginLeft: 'auto',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
child: {
|
||||
borderWidth: 0,
|
||||
borderTopWidth: 0,
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationCause, ModerationDecision} from '@atproto/api'
|
||||
|
||||
import {getModerationCauseKey} from 'lib/moderation'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText, ButtonIcon} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
|
||||
export function ProfileHeaderAlerts({
|
||||
moderation,
|
||||
style,
|
||||
}: {
|
||||
moderation: ModerationDecision
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const modui = moderation.ui('profileView')
|
||||
if (!modui.alert && !modui.inform) {
|
||||
return null
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileLabel({cause}: {cause: ModerationCause}) {
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const desc = useModerationCauseDescription(cause)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={desc.name}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
shape="default"
|
||||
onPress={() => {
|
||||
control.open()
|
||||
}}
|
||||
style={[a.px_sm, a.py_xs, a.gap_xs]}>
|
||||
<ButtonIcon icon={desc.icon} position="left" />
|
||||
<ButtonText style={[a.text_left, a.leading_snug]}>
|
||||
{desc.name}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
TouchableWithoutFeedback,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
|
||||
export function ScreenHider({
|
||||
testID,
|
||||
screenDescription,
|
||||
modui,
|
||||
style,
|
||||
containerStyle,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
testID?: string
|
||||
screenDescription: string
|
||||
modui: ModerationUI
|
||||
style?: StyleProp<ViewStyle>
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const [override, setOverride] = React.useState(false)
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const blur = modui.blurs[0]
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
if (!blur || override) {
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const isNoPwi = !!modui.blurs.find(
|
||||
cause =>
|
||||
cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated',
|
||||
)
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
{
|
||||
paddingTop: 100,
|
||||
paddingBottom: 150,
|
||||
},
|
||||
t.atoms.bg,
|
||||
containerStyle,
|
||||
]}
|
||||
sideBorders>
|
||||
<View style={[a.align_center, a.mb_md]}>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg_contrast_975,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
borderRadius: 25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
},
|
||||
]}>
|
||||
<desc.icon width={24} fill={t.atoms.bg.backgroundColor} />
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_4xl,
|
||||
a.font_semibold,
|
||||
a.text_center,
|
||||
a.mb_md,
|
||||
t.atoms.text,
|
||||
]}>
|
||||
{isNoPwi ? (
|
||||
<Trans>Sign-in Required</Trans>
|
||||
) : (
|
||||
<Trans>Content Warning</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.mb_md,
|
||||
a.px_lg,
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{isNoPwi ? (
|
||||
<Trans>
|
||||
This account has requested that users sign in to view their profile.
|
||||
</Trans>
|
||||
) : (
|
||||
<>
|
||||
<Trans>This {screenDescription} has been flagged:</Trans>
|
||||
<Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}>
|
||||
{desc.name}.{' '}
|
||||
</Text>
|
||||
<TouchableWithoutFeedback
|
||||
onPress={() => {
|
||||
control.open()
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Learn more about this warning`)}
|
||||
accessibilityHint="">
|
||||
<Text style={[a.text_lg, {color: t.palette.primary_500}]}>
|
||||
<Trans>Learn More</Trans>
|
||||
</Text>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||
</>
|
||||
)}{' '}
|
||||
</Text>
|
||||
{isMobile && <View style={a.flex_1} />}
|
||||
<View style={[a.flex_row, a.justify_center, a.my_md, a.gap_md]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
style={[a.rounded_full]}
|
||||
label={_(msg`Go back`)}
|
||||
onPress={() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Home')
|
||||
}
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Go back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{!modui.noOverride && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
style={[a.rounded_full]}
|
||||
label={_(msg`Show anyway`)}
|
||||
onPress={() => setOverride(v => !v)}>
|
||||
<ButtonText>
|
||||
<Trans>Show anyway</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
@@ -1,692 +0,0 @@
|
||||
import {describe, it, expect} from '@jest/globals'
|
||||
import {RichText} from '@atproto/api'
|
||||
|
||||
import {hasMutedWord} from '../moderatePost_wrapped'
|
||||
|
||||
describe(`hasMutedWord`, () => {
|
||||
describe(`tags`, () => {
|
||||
it(`match: outline tag`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post #inlineTag`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'outlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: inline tag`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post #inlineTag`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: content target matches inline tag`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post #inlineTag`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: only tag targets`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`early exits`, () => {
|
||||
it(`match: single character 希`, () => {
|
||||
/**
|
||||
* @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
|
||||
*/
|
||||
const rt = new RichText({
|
||||
text: `改善希望です`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: '希', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: long muted word, short post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `hey`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'politics', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: exact text`, () => {
|
||||
const rt = new RichText({
|
||||
text: `javascript`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'javascript', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`general content`, () => {
|
||||
it(`match: word within post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post about javascript`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'javascript', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: partial word`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Use your brain, Eric`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'ai', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: multiline`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Use your\n\tbrain, Eric`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'brain', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: :)`, () => {
|
||||
const rt = new RichText({
|
||||
text: `So happy :)`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `:)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`punctuation semi-fuzzy`, () => {
|
||||
describe(`yay!`, () => {
|
||||
const rt = new RichText({
|
||||
text: `We're federating, yay!`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: yay!`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'yay!', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: yay`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'yay', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`y!ppee!!`, () => {
|
||||
const rt = new RichText({
|
||||
text: `We're federating, y!ppee!!`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: y!ppee`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'y!ppee', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
// single exclamation point, source has double
|
||||
it(`no match: y!ppee!`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'y!ppee!', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`Why so S@assy?`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Why so S@assy?`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: S@assy`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'S@assy', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: s@assy`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 's@assy', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`New York Times`, () => {
|
||||
const rt = new RichText({
|
||||
text: `New York Times`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
// case insensitive
|
||||
it(`match: new york times`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'new york times', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`!command`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Idk maybe a bot !command`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: !command`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `!command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: command`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: !command`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Idk maybe a bot command`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `!command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`e/acc`, () => {
|
||||
const rt = new RichText({
|
||||
text: `I'm e/acc pilled`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: e/acc`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `e/acc`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: acc`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `acc`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`super-bad`, () => {
|
||||
const rt = new RichText({
|
||||
text: `I'm super-bad`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: super-bad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super-bad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: super`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: super bad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super bad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: superbad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `superbad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`idk_what_this_would_be`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Weird post with idk_what_this_would_be`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: idk what this would be`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idk what this would be`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: idk what this would be for`, () => {
|
||||
// extra word
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [
|
||||
{value: `idk what this would be for`, targets: ['content']},
|
||||
],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: idk`, () => {
|
||||
// extra word
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idk`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: idkwhatthiswouldbe`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`parentheses`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Post with context(iykyk)`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: context(iykyk)`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `context(iykyk)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: context`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `context`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: iykyk`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `iykyk`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: (iykyk)`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `(iykyk)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`🦋`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Post with 🦋`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: 🦋`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `🦋`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`phrases`, () => {
|
||||
describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
|
||||
const rt = new RichText({
|
||||
text: `I like turtles, or how I learned to stop worrying and love the internet.`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: stop worrying`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'stop worrying', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: turtles, or how`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'turtles, or how', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`languages without spaces`, () => {
|
||||
// I love turtles, or how I learned to stop worrying and love the internet
|
||||
describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
|
||||
const rt = new RichText({
|
||||
text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
// internet
|
||||
it(`match: インターネット`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'インターネット', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
languages: ['ja'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`doesn't mute own post`, () => {
|
||||
it(`does mute if it isn't own post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`doesn't mute own post when muted word is in text`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: true,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`doesn't mute own post when muted word is in tags`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute #words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['tags']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: true,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,10 @@ export const MAX_GRAPHEME_LENGTH = 300
|
||||
// but increasing limit per user feedback
|
||||
export const MAX_ALT_TEXT = 1000
|
||||
|
||||
export function IS_TEST_USER(handle?: string) {
|
||||
return handle && handle?.endsWith('.test')
|
||||
}
|
||||
|
||||
export function IS_PROD_SERVICE(url?: string) {
|
||||
return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ let refCount = 0
|
||||
function incrementRefCount() {
|
||||
if (refCount === 0) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.documentElement.style.scrollbarGutter = 'auto'
|
||||
}
|
||||
refCount++
|
||||
}
|
||||
@@ -14,6 +15,7 @@ function decrementRefCount() {
|
||||
refCount--
|
||||
if (refCount === 0) {
|
||||
document.body.style.overflow = ''
|
||||
document.documentElement.style.scrollbarGutter = ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-371
@@ -1,380 +1,30 @@
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
moderatePost,
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyRichtextFacet,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedExternal,
|
||||
} from '@atproto/api'
|
||||
import {moderatePost, BSKY_LABELER_DID} from '@atproto/api'
|
||||
|
||||
type ModeratePost = typeof moderatePost
|
||||
type Options = Parameters<ModeratePost>[1] & {
|
||||
hiddenPosts?: string[]
|
||||
mutedWords?: AppBskyActorDefs.MutedWord[]
|
||||
}
|
||||
|
||||
const REGEX = {
|
||||
LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
|
||||
ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
|
||||
SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g,
|
||||
WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
|
||||
}
|
||||
|
||||
/**
|
||||
* List of 2-letter lang codes for languages that either don't use spaces, or
|
||||
* don't use spaces in a way conducive to word-based filtering.
|
||||
*
|
||||
* For these, we use a simple `String.includes` to check for a match.
|
||||
*/
|
||||
const LANGUAGE_EXCEPTIONS = [
|
||||
'ja', // Japanese
|
||||
'zh', // Chinese
|
||||
'ko', // Korean
|
||||
'th', // Thai
|
||||
'vi', // Vietnamese
|
||||
]
|
||||
|
||||
export function hasMutedWord({
|
||||
mutedWords,
|
||||
text,
|
||||
facets,
|
||||
outlineTags,
|
||||
languages,
|
||||
isOwnPost,
|
||||
}: {
|
||||
mutedWords: AppBskyActorDefs.MutedWord[]
|
||||
text: string
|
||||
facets?: AppBskyRichtextFacet.Main[]
|
||||
outlineTags?: string[]
|
||||
languages?: string[]
|
||||
isOwnPost: boolean
|
||||
}) {
|
||||
if (isOwnPost) return false
|
||||
|
||||
const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
|
||||
const tags = ([] as string[])
|
||||
.concat(outlineTags || [])
|
||||
.concat(
|
||||
facets
|
||||
?.filter(facet => {
|
||||
return facet.features.find(feature =>
|
||||
AppBskyRichtextFacet.isTag(feature),
|
||||
)
|
||||
})
|
||||
.map(t => t.features[0].tag as string) || [],
|
||||
)
|
||||
.map(t => t.toLowerCase())
|
||||
|
||||
for (const mute of mutedWords) {
|
||||
const mutedWord = mute.value.toLowerCase()
|
||||
const postText = text.toLowerCase()
|
||||
|
||||
// `content` applies to tags as well
|
||||
if (tags.includes(mutedWord)) return true
|
||||
// rest of the checks are for `content` only
|
||||
if (!mute.targets.includes('content')) continue
|
||||
// single character or other exception, has to use includes
|
||||
if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord))
|
||||
return true
|
||||
// too long
|
||||
if (mutedWord.length > postText.length) continue
|
||||
// exact match
|
||||
if (mutedWord === postText) return true
|
||||
// any muted phrase with space or punctuation
|
||||
if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
|
||||
return true
|
||||
|
||||
// check individual character groups
|
||||
const words = postText.split(REGEX.WORD_BOUNDARY)
|
||||
for (const word of words) {
|
||||
if (word === mutedWord) return true
|
||||
|
||||
// compare word without leading/trailing punctuation, but allow internal
|
||||
// punctuation (such as `s@ssy`)
|
||||
const wordTrimmedPunctuation = word.replace(
|
||||
REGEX.LEADING_TRAILING_PUNCTUATION,
|
||||
'',
|
||||
)
|
||||
|
||||
if (mutedWord === wordTrimmedPunctuation) return true
|
||||
if (mutedWord.length > wordTrimmedPunctuation.length) continue
|
||||
|
||||
// handle hyphenated, slash separated words, etc
|
||||
if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
|
||||
// check against full normalized phrase
|
||||
const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
|
||||
REGEX.SEPARATORS,
|
||||
' ',
|
||||
)
|
||||
const mutedWordNormalizedSeparators = mutedWord.replace(
|
||||
REGEX.SEPARATORS,
|
||||
' ',
|
||||
)
|
||||
// hyphenated (or other sep) to spaced words
|
||||
if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
|
||||
return true
|
||||
|
||||
/* Disabled for now e.g. `super-cool` to `supercool`
|
||||
const wordNormalizedCompressed = wordNormalizedSeparators.replace(
|
||||
REGEX.WORD_BOUNDARY,
|
||||
'',
|
||||
)
|
||||
const mutedWordNormalizedCompressed =
|
||||
mutedWordNormalizedSeparators.replace(/\s+?/g, '')
|
||||
// hyphenated (or other sep) to non-hyphenated contiguous word
|
||||
if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
|
||||
return true
|
||||
*/
|
||||
|
||||
// then individual parts of separated phrases/words
|
||||
const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
|
||||
for (const wp of wordParts) {
|
||||
// still retain internal punctuation
|
||||
if (wp === mutedWord) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
type Options = Parameters<ModeratePost>[1]
|
||||
|
||||
export function moderatePost_wrapped(
|
||||
subject: Parameters<ModeratePost>[0],
|
||||
opts: Options,
|
||||
) {
|
||||
const {hiddenPosts = [], mutedWords = [], ...options} = opts
|
||||
const moderations = moderatePost(subject, options)
|
||||
const isOwnPost = subject.author.did === opts.userDid
|
||||
// HACK
|
||||
// temporarily translate 'gore' into 'graphic-media' during the transition period
|
||||
// can remove this in a few months
|
||||
// -prf
|
||||
translateOldLabels(subject)
|
||||
|
||||
if (hiddenPosts.includes(subject.uri)) {
|
||||
moderations.content.filter = true
|
||||
moderations.content.blur = true
|
||||
if (!moderations.content.cause) {
|
||||
moderations.content.cause = {
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
type: 'post-hidden',
|
||||
source: {type: 'user'},
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyFeedPost.isRecord(subject.record)) {
|
||||
let muted = hasMutedWord({
|
||||
mutedWords,
|
||||
text: subject.record.text,
|
||||
facets: subject.record.facets || [],
|
||||
outlineTags: subject.record.tags || [],
|
||||
languages: subject.record.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
|
||||
if (
|
||||
subject.record.embed &&
|
||||
AppBskyEmbedImages.isMain(subject.record.embed)
|
||||
) {
|
||||
for (const image of subject.record.embed.images) {
|
||||
muted =
|
||||
muted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: subject.record.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (muted) {
|
||||
moderations.content.filter = true
|
||||
moderations.content.blur = true
|
||||
if (!moderations.content.cause) {
|
||||
moderations.content.cause = {
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
type: 'muted-word',
|
||||
source: {type: 'user'},
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (subject.embed) {
|
||||
let embedHidden = false
|
||||
let embedMuted = false
|
||||
let externalMuted = false
|
||||
|
||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
||||
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
||||
) {
|
||||
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
||||
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
|
||||
const embeddedPost = subject.embed.record.value
|
||||
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: embeddedPost.text,
|
||||
facets: embeddedPost.facets,
|
||||
outlineTags: embeddedPost.tags,
|
||||
languages: embeddedPost.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
|
||||
if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
|
||||
for (const image of embeddedPost.embed.images) {
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: embeddedPost.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
|
||||
const {external} = embeddedPost.embed
|
||||
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: external.title + ' ' + external.description,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
|
||||
if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
|
||||
const {external} = embeddedPost.embed.media
|
||||
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: external.title + ' ' + external.description,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
|
||||
for (const image of embeddedPost.embed.media.images) {
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: AppBskyFeedPost.isRecord(embeddedPost.record)
|
||||
? embeddedPost.langs
|
||||
: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedExternal.isView(subject.embed)) {
|
||||
const {external} = subject.embed
|
||||
|
||||
externalMuted =
|
||||
externalMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: external.title + ' ' + external.description,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
||||
) {
|
||||
if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
|
||||
const post = subject.embed.record.record.value
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: post.text,
|
||||
facets: post.facets,
|
||||
outlineTags: post.tags,
|
||||
languages: post.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (AppBskyEmbedImages.isView(subject.embed.media)) {
|
||||
for (const image of subject.embed.media.images) {
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: AppBskyFeedPost.isRecord(subject.record)
|
||||
? subject.record.langs
|
||||
: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (embedHidden) {
|
||||
moderations.embed.filter = true
|
||||
moderations.embed.blur = true
|
||||
if (!moderations.embed.cause) {
|
||||
moderations.embed.cause = {
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
type: 'post-hidden',
|
||||
source: {type: 'user'},
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
} else if (externalMuted || embedMuted) {
|
||||
moderations.content.filter = true
|
||||
moderations.content.blur = true
|
||||
if (!moderations.content.cause) {
|
||||
moderations.content.cause = {
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
type: 'muted-word',
|
||||
source: {type: 'user'},
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return moderations
|
||||
return moderatePost(subject, opts)
|
||||
}
|
||||
|
||||
function translateOldLabels(subject: Parameters<ModeratePost>[0]) {
|
||||
if (subject.labels) {
|
||||
for (const label of subject.labels) {
|
||||
if (
|
||||
label.val === 'gore' &&
|
||||
(!label.src || label.src === BSKY_LABELER_DID)
|
||||
) {
|
||||
label.val = 'graphic-media'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+68
-136
@@ -1,144 +1,20 @@
|
||||
import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api'
|
||||
import {
|
||||
ModerationCause,
|
||||
ModerationUI,
|
||||
InterpretedLabelValueDefinition,
|
||||
LABELS,
|
||||
AppBskyLabelerDefs,
|
||||
BskyAgent,
|
||||
ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
|
||||
export interface ModerationCauseDescription {
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function describeModerationCause(
|
||||
cause: ModerationCause | undefined,
|
||||
context: 'account' | 'content',
|
||||
): ModerationCauseDescription {
|
||||
if (!cause) {
|
||||
return {
|
||||
name: 'Content Warning',
|
||||
description:
|
||||
'Moderator has chosen to set a general warning on the content.',
|
||||
}
|
||||
}
|
||||
if (cause.type === 'blocking') {
|
||||
if (cause.source.type === 'list') {
|
||||
return {
|
||||
name: `User Blocked by "${cause.source.list.name}"`,
|
||||
description:
|
||||
'You have blocked this user. You cannot view their content.',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: 'User Blocked',
|
||||
description:
|
||||
'You have blocked this user. You cannot view their content.',
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cause.type === 'blocked-by') {
|
||||
return {
|
||||
name: 'User Blocking You',
|
||||
description: 'This user has blocked you. You cannot view their content.',
|
||||
}
|
||||
}
|
||||
if (cause.type === 'block-other') {
|
||||
return {
|
||||
name: 'Content Not Available',
|
||||
description:
|
||||
'This content is not available because one of the users involved has blocked the other.',
|
||||
}
|
||||
}
|
||||
if (cause.type === 'muted') {
|
||||
if (cause.source.type === 'list') {
|
||||
return {
|
||||
name:
|
||||
context === 'account'
|
||||
? `Muted by "${cause.source.list.name}"`
|
||||
: `Post by muted user ("${cause.source.list.name}")`,
|
||||
description: 'You have muted this user',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: context === 'account' ? 'Muted User' : 'Post by muted user',
|
||||
description: 'You have muted this user',
|
||||
}
|
||||
}
|
||||
}
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
if (cause.type === 'post-hidden') {
|
||||
return {
|
||||
name: 'Post Hidden by You',
|
||||
description: 'You have hidden this post',
|
||||
}
|
||||
}
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
if (cause.type === 'muted-word') {
|
||||
return {
|
||||
name: 'Post hidden by muted word',
|
||||
description: `You've chosen to hide a word or tag within this post.`,
|
||||
}
|
||||
}
|
||||
return cause.labelDef.strings[context].en
|
||||
}
|
||||
|
||||
export function getProfileModerationCauses(
|
||||
moderation: ProfileModeration,
|
||||
): ModerationCause[] {
|
||||
/*
|
||||
Gather everything on profile and account that blurs or alerts
|
||||
*/
|
||||
return [
|
||||
moderation.decisions.profile.cause,
|
||||
...moderation.decisions.profile.additionalCauses,
|
||||
moderation.decisions.account.cause,
|
||||
...moderation.decisions.account.additionalCauses,
|
||||
].filter(cause => {
|
||||
if (!cause) {
|
||||
return false
|
||||
}
|
||||
if (cause?.type === 'label') {
|
||||
if (
|
||||
cause.labelDef.onwarn === 'blur' ||
|
||||
cause.labelDef.onwarn === 'alert'
|
||||
) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}) as ModerationCause[]
|
||||
}
|
||||
|
||||
export function isPostMediaBlurred(
|
||||
decisions: PostModeration['decisions'],
|
||||
): boolean {
|
||||
return decisions.post.blurMedia
|
||||
}
|
||||
|
||||
export function isQuoteBlurred(
|
||||
decisions: PostModeration['decisions'],
|
||||
): boolean {
|
||||
return (
|
||||
decisions.quote?.blur ||
|
||||
decisions.quote?.blurMedia ||
|
||||
decisions.quote?.filter ||
|
||||
decisions.quotedAccount?.blur ||
|
||||
decisions.quotedAccount?.filter ||
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
export function isCauseALabelOnUri(
|
||||
cause: ModerationCause | undefined,
|
||||
uri: string,
|
||||
): boolean {
|
||||
if (cause?.type !== 'label') {
|
||||
return false
|
||||
}
|
||||
return cause.label.uri === uri
|
||||
}
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
|
||||
export function getModerationCauseKey(cause: ModerationCause): string {
|
||||
const source =
|
||||
cause.source.type === 'labeler'
|
||||
? cause.source.labeler.did
|
||||
? cause.source.did
|
||||
: cause.source.type === 'list'
|
||||
? cause.source.list.uri
|
||||
: 'user'
|
||||
@@ -147,3 +23,59 @@ export function getModerationCauseKey(cause: ModerationCause): string {
|
||||
}
|
||||
return `${cause.type}:${source}`
|
||||
}
|
||||
|
||||
export function isJustAMute(modui: ModerationUI): boolean {
|
||||
return modui.filters.length === 1 && modui.filters[0].type === 'muted'
|
||||
}
|
||||
|
||||
export function getLabelingServiceTitle({
|
||||
displayName,
|
||||
handle,
|
||||
}: {
|
||||
displayName?: string
|
||||
handle: string
|
||||
}) {
|
||||
return displayName
|
||||
? sanitizeDisplayName(displayName)
|
||||
: sanitizeHandle(handle, '@')
|
||||
}
|
||||
|
||||
export function lookupLabelValueDefinition(
|
||||
labelValue: string,
|
||||
customDefs: InterpretedLabelValueDefinition[] | undefined,
|
||||
): InterpretedLabelValueDefinition | undefined {
|
||||
let def
|
||||
if (!labelValue.startsWith('!') && customDefs) {
|
||||
def = customDefs.find(d => d.identifier === labelValue)
|
||||
}
|
||||
if (!def) {
|
||||
def = LABELS[labelValue as keyof typeof LABELS]
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
export function isAppLabeler(
|
||||
labeler:
|
||||
| string
|
||||
| AppBskyLabelerDefs.LabelerView
|
||||
| AppBskyLabelerDefs.LabelerViewDetailed,
|
||||
): boolean {
|
||||
if (typeof labeler === 'string') {
|
||||
return BskyAgent.appLabelers.includes(labeler)
|
||||
}
|
||||
return BskyAgent.appLabelers.includes(labeler.creator.did)
|
||||
}
|
||||
|
||||
export function isLabelerSubscribed(
|
||||
labeler:
|
||||
| string
|
||||
| AppBskyLabelerDefs.LabelerView
|
||||
| AppBskyLabelerDefs.LabelerViewDetailed,
|
||||
modOpts: ModerationOpts,
|
||||
) {
|
||||
labeler = typeof labeler === 'string' ? labeler : labeler.creator.did
|
||||
if (isAppLabeler(labeler)) {
|
||||
return true
|
||||
}
|
||||
return modOpts.prefs.labelers.find(l => l.did === labeler)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMemo} from 'react'
|
||||
|
||||
export type GlobalLabelStrings = Record<
|
||||
string,
|
||||
{
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
>
|
||||
|
||||
export function useGlobalLabelStrings(): GlobalLabelStrings {
|
||||
const {_} = useLingui()
|
||||
return useMemo(
|
||||
() => ({
|
||||
'!hide': {
|
||||
name: _(msg`Content Blocked`),
|
||||
description: _(msg`This content has been hidden by the moderators.`),
|
||||
},
|
||||
'!warn': {
|
||||
name: _(msg`Content Warning`),
|
||||
description: _(
|
||||
msg`This content has received a general warning from moderators.`,
|
||||
),
|
||||
},
|
||||
'!no-unauthenticated': {
|
||||
name: _(msg`Sign-in Required`),
|
||||
description: _(
|
||||
msg`This user has requested that their content only be shown to signed-in users.`,
|
||||
),
|
||||
},
|
||||
porn: {
|
||||
name: _(msg`Pornography`),
|
||||
description: _(msg`Explicit sexual images.`),
|
||||
},
|
||||
sexual: {
|
||||
name: _(msg`Sexually Suggestive`),
|
||||
description: _(msg`Does not include nudity.`),
|
||||
},
|
||||
nudity: {
|
||||
name: _(msg`Non-sexual Nudity`),
|
||||
description: _(msg`E.g. artistic nudes.`),
|
||||
},
|
||||
'graphic-media': {
|
||||
name: _(msg`Graphic Media`),
|
||||
description: _(msg`Explicit or potentially disturbing media.`),
|
||||
},
|
||||
}),
|
||||
[_],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
export function useLabelBehaviorDescription(
|
||||
labelValueDef: InterpretedLabelValueDefinition,
|
||||
pref: LabelPreference,
|
||||
) {
|
||||
const {_} = useLingui()
|
||||
if (pref === 'ignore') {
|
||||
return _(msg`Off`)
|
||||
}
|
||||
if (labelValueDef.blurs === 'content' || labelValueDef.blurs === 'media') {
|
||||
if (pref === 'hide') {
|
||||
return _(msg`Hide`)
|
||||
}
|
||||
return _(msg`Warn`)
|
||||
} else if (labelValueDef.severity === 'alert') {
|
||||
if (pref === 'hide') {
|
||||
return _(msg`Hide`)
|
||||
}
|
||||
return _(msg`Warn`)
|
||||
} else if (labelValueDef.severity === 'inform') {
|
||||
if (pref === 'hide') {
|
||||
return _(msg`Hide`)
|
||||
}
|
||||
return _(msg`Show badge`)
|
||||
} else {
|
||||
if (pref === 'hide') {
|
||||
return _(msg`Hide`)
|
||||
}
|
||||
return _(msg`Disabled`)
|
||||
}
|
||||
}
|
||||
|
||||
export function useLabelLongBehaviorDescription(
|
||||
labelValueDef: InterpretedLabelValueDefinition,
|
||||
pref: LabelPreference,
|
||||
) {
|
||||
const {_} = useLingui()
|
||||
if (pref === 'ignore') {
|
||||
return _(msg`Disabled`)
|
||||
}
|
||||
if (labelValueDef.blurs === 'content') {
|
||||
if (pref === 'hide') {
|
||||
return _(msg`Warn content and filter from feeds`)
|
||||
}
|
||||
return _(msg`Warn content`)
|
||||
} else if (labelValueDef.blurs === 'media') {
|
||||
if (pref === 'hide') {
|
||||
return _(msg`Blur images and filter from feeds`)
|
||||
}
|
||||
return _(msg`Blur images`)
|
||||
} else if (labelValueDef.severity === 'alert') {
|
||||
if (pref === 'hide') {
|
||||
return _(msg`Show warning and filter from feeds`)
|
||||
}
|
||||
return _(msg`Show warning`)
|
||||
} else if (labelValueDef.severity === 'inform') {
|
||||
if (pref === 'hide') {
|
||||
return _(msg`Show badge and filter from feeds`)
|
||||
}
|
||||
return _(msg`Show badge`)
|
||||
} else {
|
||||
if (pref === 'hide') {
|
||||
return _(msg`Filter from feeds`)
|
||||
}
|
||||
return _(msg`Disabled`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
ComAtprotoLabelDefs,
|
||||
AppBskyLabelerDefs,
|
||||
LABELS,
|
||||
interpretLabelValueDefinition,
|
||||
InterpretedLabelValueDefinition,
|
||||
} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as bcp47Match from 'bcp-47-match'
|
||||
|
||||
import {
|
||||
GlobalLabelStrings,
|
||||
useGlobalLabelStrings,
|
||||
} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
import {useLabelDefinitions} from '#/state/preferences'
|
||||
|
||||
export interface LabelInfo {
|
||||
label: ComAtprotoLabelDefs.Label
|
||||
def: InterpretedLabelValueDefinition
|
||||
strings: ComAtprotoLabelDefs.LabelValueDefinitionStrings
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
|
||||
}
|
||||
|
||||
export function useLabelInfo(label: ComAtprotoLabelDefs.Label): LabelInfo {
|
||||
const {i18n} = useLingui()
|
||||
const {labelDefs, labelers} = useLabelDefinitions()
|
||||
const globalLabelStrings = useGlobalLabelStrings()
|
||||
const def = getDefinition(labelDefs, label)
|
||||
return {
|
||||
label,
|
||||
def,
|
||||
strings: getLabelStrings(i18n.locale, globalLabelStrings, def),
|
||||
labeler: labelers.find(labeler => label.src === labeler.creator.did),
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefinition(
|
||||
labelDefs: Record<string, InterpretedLabelValueDefinition[]>,
|
||||
label: ComAtprotoLabelDefs.Label,
|
||||
): InterpretedLabelValueDefinition {
|
||||
// check local definitions
|
||||
const customDef =
|
||||
!label.val.startsWith('!') &&
|
||||
labelDefs[label.src]?.find(
|
||||
def => def.identifier === label.val && def.definedBy === label.src,
|
||||
)
|
||||
if (customDef) {
|
||||
return customDef
|
||||
}
|
||||
|
||||
// check global definitions
|
||||
const globalDef = LABELS[label.val as keyof typeof LABELS]
|
||||
if (globalDef) {
|
||||
return globalDef
|
||||
}
|
||||
|
||||
// fallback to a noop definition
|
||||
return interpretLabelValueDefinition(
|
||||
{
|
||||
identifier: label.val,
|
||||
severity: 'none',
|
||||
blurs: 'none',
|
||||
defaultSetting: 'ignore',
|
||||
locales: [],
|
||||
},
|
||||
label.src,
|
||||
)
|
||||
}
|
||||
|
||||
export function getLabelStrings(
|
||||
locale: string,
|
||||
globalLabelStrings: GlobalLabelStrings,
|
||||
def: InterpretedLabelValueDefinition,
|
||||
): ComAtprotoLabelDefs.LabelValueDefinitionStrings {
|
||||
if (!def.definedBy) {
|
||||
// global definition, look up strings
|
||||
if (def.identifier in globalLabelStrings) {
|
||||
return globalLabelStrings[
|
||||
def.identifier
|
||||
] as ComAtprotoLabelDefs.LabelValueDefinitionStrings
|
||||
}
|
||||
} else {
|
||||
// try to find locale match in the definition's strings
|
||||
const localeMatch = def.locales.find(
|
||||
strings => bcp47Match.basicFilter(locale, strings.lang).length > 0,
|
||||
)
|
||||
if (localeMatch) {
|
||||
return localeMatch
|
||||
}
|
||||
// fall back to the zero item if no match
|
||||
if (def.locales[0]) {
|
||||
return def.locales[0]
|
||||
}
|
||||
}
|
||||
return {
|
||||
lang: locale,
|
||||
name: def.identifier,
|
||||
description: `Labeled "${def.identifier}"`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
BSKY_LABELER_DID,
|
||||
ModerationCause,
|
||||
ModerationCauseSource,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {getDefinition, getLabelStrings} from './useLabelInfo'
|
||||
import {useLabelDefinitions} from '#/state/preferences'
|
||||
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
|
||||
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||
|
||||
export interface ModerationCauseDescription {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
name: string
|
||||
description: string
|
||||
source?: string
|
||||
sourceType?: ModerationCauseSource['type']
|
||||
}
|
||||
|
||||
export function useModerationCauseDescription(
|
||||
cause: ModerationCause | undefined,
|
||||
): ModerationCauseDescription {
|
||||
const {_, i18n} = useLingui()
|
||||
const {labelDefs, labelers} = useLabelDefinitions()
|
||||
const globalLabelStrings = useGlobalLabelStrings()
|
||||
|
||||
return React.useMemo(() => {
|
||||
if (!cause) {
|
||||
return {
|
||||
icon: Warning,
|
||||
name: _(msg`Content Warning`),
|
||||
description: _(
|
||||
msg`Moderator has chosen to set a general warning on the content.`,
|
||||
),
|
||||
}
|
||||
}
|
||||
if (cause.type === 'blocking') {
|
||||
if (cause.source.type === 'list') {
|
||||
return {
|
||||
icon: CircleBanSign,
|
||||
name: _(msg`User Blocked by "${cause.source.list.name}"`),
|
||||
description: _(
|
||||
msg`You have blocked this user. You cannot view their content.`,
|
||||
),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
icon: CircleBanSign,
|
||||
name: _(msg`User Blocked`),
|
||||
description: _(
|
||||
msg`You have blocked this user. You cannot view their content.`,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cause.type === 'blocked-by') {
|
||||
return {
|
||||
icon: CircleBanSign,
|
||||
name: _(msg`User Blocking You`),
|
||||
description: _(
|
||||
msg`This user has blocked you. You cannot view their content.`,
|
||||
),
|
||||
}
|
||||
}
|
||||
if (cause.type === 'block-other') {
|
||||
return {
|
||||
icon: CircleBanSign,
|
||||
name: _(msg`Content Not Available`),
|
||||
description: _(
|
||||
msg`This content is not available because one of the users involved has blocked the other.`,
|
||||
),
|
||||
}
|
||||
}
|
||||
if (cause.type === 'muted') {
|
||||
if (cause.source.type === 'list') {
|
||||
return {
|
||||
icon: EyeSlash,
|
||||
name: _(msg`Muted by "${cause.source.list.name}"`),
|
||||
description: _(msg`You have muted this user`),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
icon: EyeSlash,
|
||||
name: _(msg`Account Muted`),
|
||||
description: _(msg`You have muted this account.`),
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cause.type === 'mute-word') {
|
||||
return {
|
||||
icon: EyeSlash,
|
||||
name: _(msg`Post Hidden by Muted Word`),
|
||||
description: _(
|
||||
msg`You've chosen to hide a word or tag within this post.`,
|
||||
),
|
||||
}
|
||||
}
|
||||
if (cause.type === 'hidden') {
|
||||
return {
|
||||
icon: EyeSlash,
|
||||
name: _(msg`Post Hidden by You`),
|
||||
description: _(msg`You have hidden this post`),
|
||||
}
|
||||
}
|
||||
if (cause.type === 'label') {
|
||||
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
|
||||
const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
|
||||
const labeler = labelers.find(l => l.creator.did === cause.label.src)
|
||||
let source =
|
||||
labeler?.creator.displayName ||
|
||||
(labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined)
|
||||
if (!source) {
|
||||
if (cause.label.src === BSKY_LABELER_DID) {
|
||||
source = 'Bluesky Moderation'
|
||||
} else {
|
||||
source = cause.label.src
|
||||
}
|
||||
}
|
||||
return {
|
||||
icon:
|
||||
def.identifier === '!no-unauthenticated'
|
||||
? EyeSlash
|
||||
: def.severity === 'alert'
|
||||
? Warning
|
||||
: CircleInfo,
|
||||
name: strings.name,
|
||||
description: strings.description,
|
||||
source,
|
||||
sourceType: cause.source.type,
|
||||
}
|
||||
}
|
||||
// should never happen
|
||||
return {
|
||||
icon: CircleInfo,
|
||||
name: '',
|
||||
description: ``,
|
||||
}
|
||||
}, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMemo} from 'react'
|
||||
import {ComAtprotoModerationDefs} from '@atproto/api'
|
||||
|
||||
export interface ReportOption {
|
||||
reason: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface ReportOptions {
|
||||
account: ReportOption[]
|
||||
post: ReportOption[]
|
||||
list: ReportOption[]
|
||||
feedgen: ReportOption[]
|
||||
other: ReportOption[]
|
||||
}
|
||||
|
||||
export function useReportOptions(): ReportOptions {
|
||||
const {_} = useLingui()
|
||||
return useMemo(() => {
|
||||
const other = {
|
||||
reason: ComAtprotoModerationDefs.REASONOTHER,
|
||||
title: _(msg`Other`),
|
||||
description: _(msg`An issue not included in these options`),
|
||||
}
|
||||
const common = [
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONRUDE,
|
||||
title: _(msg`Anti-Social Behavior`),
|
||||
description: _(msg`Harassment, trolling, or intolerance`),
|
||||
},
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||
title: _(msg`Illegal and Urgent`),
|
||||
description: _(msg`Glaring violations of law or terms of service`),
|
||||
},
|
||||
other,
|
||||
]
|
||||
return {
|
||||
account: [
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONMISLEADING,
|
||||
title: _(msg`Misleading Account`),
|
||||
description: _(
|
||||
msg`Impersonation or false claims about identity or affiliation`,
|
||||
),
|
||||
},
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONSPAM,
|
||||
title: _(msg`Frequently Posts Unwanted Content`),
|
||||
description: _(msg`Spam; excessive mentions or replies`),
|
||||
},
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||
title: _(msg`Name or Description Violates Community Standards`),
|
||||
description: _(msg`Terms used violate community standards`),
|
||||
},
|
||||
other,
|
||||
],
|
||||
post: [
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONSPAM,
|
||||
title: _(msg`Spam`),
|
||||
description: _(msg`Excessive mentions or replies`),
|
||||
},
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONSEXUAL,
|
||||
title: _(msg`Unwanted Sexual Content`),
|
||||
description: _(msg`Nudity or pornography not labeled as such`),
|
||||
},
|
||||
...common,
|
||||
],
|
||||
list: [
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||
title: _(msg`Name or Description Violates Community Standards`),
|
||||
description: _(msg`Terms used violate community standards`),
|
||||
},
|
||||
...common,
|
||||
],
|
||||
feedgen: [
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONVIOLATION,
|
||||
title: _(msg`Name or Description Violates Community Standards`),
|
||||
description: _(msg`Terms used violate community standards`),
|
||||
},
|
||||
...common,
|
||||
],
|
||||
other: common,
|
||||
}
|
||||
}, [_])
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {logger} from '#/logger'
|
||||
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {SessionAccount, getAgent} from '#/state/session'
|
||||
import {logEvent} from '../statsig/statsig'
|
||||
|
||||
const SERVICE_DID = (serviceUrl?: string) =>
|
||||
serviceUrl?.includes('staging')
|
||||
@@ -123,6 +124,7 @@ export function init(queryClient: QueryClient) {
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
track('Notificatons:OpenApp')
|
||||
logEvent('notifications:openApp', {})
|
||||
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
|
||||
resetToTab('NotificationsTab') // open notifications tab
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import {QueryClient, focusManager} from '@tanstack/react-query'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {PersistQueryClientProviderProps} from '@tanstack/react-query-persist-client'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
// any query keys in this array will be persisted to AsyncStorage
|
||||
const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info']
|
||||
|
||||
focusManager.setEventListener(onFocus => {
|
||||
if (isNative) {
|
||||
const subscription = AppState.addEventListener(
|
||||
@@ -48,3 +55,16 @@ export const queryClient = new QueryClient({
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const asyncStoragePersister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: 'queryCache',
|
||||
})
|
||||
|
||||
export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] =
|
||||
{
|
||||
shouldDehydrateMutation: (_: any) => false,
|
||||
shouldDehydrateQuery: query => {
|
||||
return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0]))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ export type CommonNavigatorParams = {
|
||||
PostRepostedBy: {name: string; rkey: string}
|
||||
ProfileFeed: {name: string; rkey: string}
|
||||
ProfileFeedLikedBy: {name: string; rkey: string}
|
||||
ProfileLabelerLikedBy: {name: string}
|
||||
Debug: undefined
|
||||
DebugMod: undefined
|
||||
Log: undefined
|
||||
Support: undefined
|
||||
PrivacyPolicy: undefined
|
||||
|
||||
@@ -2,6 +2,9 @@ export type LogEvents = {
|
||||
init: {
|
||||
initMs: number
|
||||
}
|
||||
'notifications:openApp': {}
|
||||
'state:background': {}
|
||||
'state:foreground': {}
|
||||
'feed:endReached': {
|
||||
feedType: string
|
||||
itemCount: number
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import {
|
||||
Statsig,
|
||||
StatsigProvider,
|
||||
useGate as useStatsigGate,
|
||||
} from 'statsig-react-native-expo'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import {useSession} from '../../state/session'
|
||||
import {sha256} from 'js-sha256'
|
||||
import {LogEvents} from './events'
|
||||
@@ -58,9 +60,25 @@ function toStatsigUser(did: string | undefined) {
|
||||
if (did) {
|
||||
userID = sha256(did)
|
||||
}
|
||||
return {userID}
|
||||
return {
|
||||
userID,
|
||||
platform: Platform.OS,
|
||||
}
|
||||
}
|
||||
|
||||
let lastState: AppStateStatus = AppState.currentState
|
||||
AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||
if (state === lastState) {
|
||||
return
|
||||
}
|
||||
lastState = state
|
||||
if (state === 'active') {
|
||||
logEvent('state:foreground', {})
|
||||
} else {
|
||||
logEvent('state:background', {})
|
||||
}
|
||||
})
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const {currentAccount} = useSession()
|
||||
const currentStatsigUser = React.useMemo(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {describeModerationCause} from '../moderation'
|
||||
|
||||
// \u2705 = ✅
|
||||
// \u2713 = ✓
|
||||
@@ -14,7 +13,7 @@ export function sanitizeDisplayName(
|
||||
moderation?: ModerationUI,
|
||||
): string {
|
||||
if (moderation?.blur) {
|
||||
return `⚠${describeModerationCause(moderation.cause, 'account').name}`
|
||||
return ''
|
||||
}
|
||||
if (typeof str === 'string') {
|
||||
return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
|
||||
|
||||
@@ -2,6 +2,15 @@ import {Dimensions} from 'react-native'
|
||||
import {isWeb} from 'platform/detection'
|
||||
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
||||
|
||||
const IFRAME_HOST = isWeb
|
||||
? // @ts-ignore only for web
|
||||
window.location.host === 'localhost:8100'
|
||||
? 'http://localhost:8100'
|
||||
: 'https://bsky.app'
|
||||
: __DEV__ && !process.env.JEST_WORKER_ID
|
||||
? 'http://localhost:8100'
|
||||
: 'https://bsky.app'
|
||||
|
||||
export const embedPlayerSources = [
|
||||
'youtube',
|
||||
'youtubeShorts',
|
||||
@@ -74,7 +83,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
return {
|
||||
type: 'youtube_video',
|
||||
source: 'youtube',
|
||||
playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
|
||||
playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +102,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
|
||||
source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
|
||||
hideDetails: page === 'shorts' ? true : undefined,
|
||||
playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
|
||||
playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,23 @@ import TLDs from 'tlds'
|
||||
import psl from 'psl'
|
||||
|
||||
export const BSKY_APP_HOST = 'https://bsky.app'
|
||||
const BSKY_TRUSTED_HOSTS = [
|
||||
'bsky.app',
|
||||
'bsky.social',
|
||||
'blueskyweb.xyz',
|
||||
'blueskyweb.zendesk.com',
|
||||
...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []),
|
||||
]
|
||||
|
||||
/*
|
||||
* This will allow any BSKY_TRUSTED_HOSTS value by itself or with a subdomain.
|
||||
* It will also allow relative paths like /profile as well as #.
|
||||
*/
|
||||
const TRUSTED_REGEX = new RegExp(
|
||||
`^(http(s)?://(([\\w-]+\\.)?${BSKY_TRUSTED_HOSTS.join(
|
||||
'|([\\w-]+\\.)?',
|
||||
)})|/|#)`,
|
||||
)
|
||||
|
||||
export function isValidDomain(str: string): boolean {
|
||||
return !!TLDs.find(tld => {
|
||||
@@ -86,6 +103,10 @@ export function isExternalUrl(url: string): boolean {
|
||||
return external || rss
|
||||
}
|
||||
|
||||
export function isTrustedUrl(url: string): boolean {
|
||||
return TRUSTED_REGEX.test(url)
|
||||
}
|
||||
|
||||
export function isBskyPostUrl(url: string): boolean {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
@@ -163,8 +184,8 @@ export function feedUriToHref(url: string): string {
|
||||
export function linkRequiresWarning(uri: string, label: string) {
|
||||
const labelDomain = labelToDomain(label)
|
||||
|
||||
// If the uri started with a / we know it is internal.
|
||||
if (isRelativeUrl(uri)) {
|
||||
// We should trust any relative URL or a # since we know it links to internal content
|
||||
if (isRelativeUrl(uri) || uri === '#') {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -176,18 +197,11 @@ export function linkRequiresWarning(uri: string, label: string) {
|
||||
}
|
||||
|
||||
const host = urip.hostname.toLowerCase()
|
||||
// Hosts that end with bsky.app or bsky.social should be trusted by default.
|
||||
if (
|
||||
host.endsWith('bsky.app') ||
|
||||
host.endsWith('bsky.social') ||
|
||||
host.endsWith('blueskyweb.xyz')
|
||||
) {
|
||||
// if this is a link to internal content,
|
||||
// warn if it represents itself as a URL to another app
|
||||
if (isTrustedUrl(uri)) {
|
||||
// if this is a link to internal content, warn if it represents itself as a URL to another app
|
||||
return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
|
||||
} else {
|
||||
// if this is a link to external content,
|
||||
// warn if the label doesnt match the target
|
||||
// if this is a link to external content, warn if the label doesnt match the target
|
||||
if (!labelDomain) {
|
||||
return true
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ export const defaultTheme: Theme = {
|
||||
palette: {
|
||||
default: {
|
||||
background: lightPalette.white,
|
||||
backgroundLight: lightPalette.contrast_50,
|
||||
backgroundLight: lightPalette.contrast_25,
|
||||
text: lightPalette.black,
|
||||
textLight: lightPalette.contrast_700,
|
||||
textInverted: lightPalette.white,
|
||||
|
||||
@@ -122,6 +122,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
||||
return AppLanguage.de
|
||||
case 'es':
|
||||
return AppLanguage.es
|
||||
case 'fi':
|
||||
return AppLanguage.fi
|
||||
case 'fr':
|
||||
return AppLanguage.fr
|
||||
case 'hi':
|
||||
|
||||
@@ -6,6 +6,7 @@ import {messages as messagesEn} from '#/locale/locales/en/messages'
|
||||
import {messages as messagesDe} from '#/locale/locales/de/messages'
|
||||
import {messages as messagesId} from '#/locale/locales/id/messages'
|
||||
import {messages as messagesEs} from '#/locale/locales/es/messages'
|
||||
import {messages as messagesFi} from '#/locale/locales/fi/messages'
|
||||
import {messages as messagesFr} from '#/locale/locales/fr/messages'
|
||||
import {messages as messagesHi} from '#/locale/locales/hi/messages'
|
||||
import {messages as messagesJa} from '#/locale/locales/ja/messages'
|
||||
@@ -32,6 +33,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
i18n.loadAndActivate({locale, messages: messagesEs})
|
||||
break
|
||||
}
|
||||
case AppLanguage.fi: {
|
||||
i18n.loadAndActivate({locale, messages: messagesFi})
|
||||
break
|
||||
}
|
||||
case AppLanguage.fr: {
|
||||
i18n.loadAndActivate({locale, messages: messagesFr})
|
||||
break
|
||||
|
||||
@@ -20,6 +20,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/es/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.fi: {
|
||||
mod = await import(`./locales/fi/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.fr: {
|
||||
mod = await import(`./locales/fr/messages`)
|
||||
break
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum AppLanguage {
|
||||
en = 'en',
|
||||
de = 'de',
|
||||
es = 'es',
|
||||
fi = 'fi',
|
||||
fr = 'fr',
|
||||
hi = 'hi',
|
||||
id = 'id',
|
||||
@@ -29,6 +30,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
|
||||
{code2: AppLanguage.en, name: 'English'},
|
||||
{code2: AppLanguage.de, name: 'Deutsch – German'},
|
||||
{code2: AppLanguage.es, name: 'Español – Spanish'},
|
||||
{code2: AppLanguage.fi, name: 'Suomi – Finnish'},
|
||||
{code2: AppLanguage.fr, name: 'Français – French'},
|
||||
{code2: AppLanguage.hi, name: 'हिंदी – Hindi'},
|
||||
{code2: AppLanguage.id, name: 'Bahasa Indonesia – Indonesian'},
|
||||
|
||||
+124
-124
@@ -244,12 +244,12 @@ msgstr "Avançat"
|
||||
|
||||
#: src/view/screens/Feeds.tsx:666
|
||||
msgid "All the feeds you've saved, right in one place."
|
||||
msgstr ""
|
||||
msgstr "Tots els canals que has desat, en un sol lloc."
|
||||
|
||||
#: src/view/com/auth/login/ForgotPasswordForm.tsx:221
|
||||
#: src/view/com/modals/ChangePassword.tsx:168
|
||||
msgid "Already have a code?"
|
||||
msgstr ""
|
||||
msgstr "Ja tens un codi?"
|
||||
|
||||
#: src/view/com/auth/login/ChooseAccountForm.tsx:98
|
||||
msgid "Already signed in as @{0}"
|
||||
@@ -287,7 +287,7 @@ msgstr "i"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:32
|
||||
msgid "Animals"
|
||||
msgstr ""
|
||||
msgstr "Animals"
|
||||
|
||||
#: src/view/screens/LanguageSettings.tsx:95
|
||||
msgid "App Language"
|
||||
@@ -366,7 +366,7 @@ msgstr "Estàs escrivint en <0>{0}</0>?"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:26
|
||||
msgid "Art"
|
||||
msgstr ""
|
||||
msgstr "Art"
|
||||
|
||||
#: src/view/com/modals/SelfLabel.tsx:123
|
||||
msgid "Artistic or non-erotic nudity."
|
||||
@@ -393,7 +393,7 @@ msgstr "Endarrere"
|
||||
|
||||
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:136
|
||||
msgid "Based on your interest in {interestsText}"
|
||||
msgstr ""
|
||||
msgstr "Segons els teus interessos en {interestsText}"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:523
|
||||
msgid "Basics"
|
||||
@@ -472,7 +472,7 @@ msgstr "Bluesky"
|
||||
|
||||
#: src/view/com/auth/server-input/index.tsx:150
|
||||
msgid "Bluesky is an open network where you can choose your hosting provider. Custom hosting is now available in beta for developers."
|
||||
msgstr ""
|
||||
msgstr "Bluesky és una xarxa oberta on pots escollir el teu proveïdor d'allotjament. L'allotjament personalitzat està disponible en beta per a desenvolupadors"
|
||||
|
||||
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:80
|
||||
#: src/view/com/auth/onboarding/WelcomeMobile.tsx:80
|
||||
@@ -503,7 +503,7 @@ msgstr "Bluesky no mostrarà el teu perfil ni les publicacions als usuaris que n
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:33
|
||||
msgid "Books"
|
||||
msgstr ""
|
||||
msgstr "Llibres"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:859
|
||||
msgid "Build version {0} {1}"
|
||||
@@ -631,11 +631,11 @@ msgstr "Canvia el meu correu"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:732
|
||||
msgid "Change password"
|
||||
msgstr ""
|
||||
msgstr "Canvia la contrasenya"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:741
|
||||
msgid "Change Password"
|
||||
msgstr ""
|
||||
msgstr "Canvia la contrasenya"
|
||||
|
||||
#: src/view/com/composer/select-language/SuggestedLanguage.tsx:73
|
||||
msgid "Change post language to {0}"
|
||||
@@ -643,7 +643,7 @@ msgstr "Canvia l'idioma de la publicació a {0}"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:733
|
||||
msgid "Change your Bluesky password"
|
||||
msgstr ""
|
||||
msgstr "Canvia la teva contrasenya de Bluesky"
|
||||
|
||||
#: src/view/com/modals/ChangeEmail.tsx:109
|
||||
msgid "Change Your Email"
|
||||
@@ -652,7 +652,7 @@ msgstr "Canvia el teu correu"
|
||||
#: src/screens/Deactivated.tsx:72
|
||||
#: src/screens/Deactivated.tsx:76
|
||||
msgid "Check my status"
|
||||
msgstr ""
|
||||
msgstr "Comprova el meu estat"
|
||||
|
||||
#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:121
|
||||
msgid "Check out some recommended feeds. Tap + to add them to your list of pinned feeds."
|
||||
@@ -680,7 +680,7 @@ msgstr "Tria un servei"
|
||||
|
||||
#: src/screens/Onboarding/StepFinished.tsx:135
|
||||
msgid "Choose the algorithms that power your custom feeds."
|
||||
msgstr ""
|
||||
msgstr "Tria els algoritmes que alimentaran els teus canals personalitzats."
|
||||
|
||||
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:83
|
||||
#: src/view/com/auth/onboarding/WelcomeMobile.tsx:83
|
||||
@@ -689,7 +689,7 @@ msgstr "Tria els algoritmes que potenciaran la teva experiència amb els canals
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:103
|
||||
msgid "Choose your main feeds"
|
||||
msgstr ""
|
||||
msgstr "Tria els teus canals principals"
|
||||
|
||||
#: src/view/com/auth/create/Step1.tsx:196
|
||||
msgid "Choose your password"
|
||||
@@ -732,12 +732,12 @@ msgstr ""
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:35
|
||||
msgid "Climate"
|
||||
msgstr ""
|
||||
msgstr "Clima"
|
||||
|
||||
#: src/view/com/modals/ChangePassword.tsx:265
|
||||
#: src/view/com/modals/ChangePassword.tsx:268
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
msgstr "Tanca"
|
||||
|
||||
#: src/components/Dialog/index.web.tsx:84
|
||||
#: src/components/Dialog/index.web.tsx:198
|
||||
@@ -790,11 +790,11 @@ msgstr "Plega la llista d'usuaris per una notificació concreta"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:41
|
||||
msgid "Comedy"
|
||||
msgstr ""
|
||||
msgstr "Comèdia"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:27
|
||||
msgid "Comics"
|
||||
msgstr ""
|
||||
msgstr "Còmics"
|
||||
|
||||
#: src/Navigation.tsx:229
|
||||
#: src/view/screens/CommunityGuidelines.tsx:32
|
||||
@@ -803,7 +803,7 @@ msgstr "Directrius de la comunitat"
|
||||
|
||||
#: src/screens/Onboarding/StepFinished.tsx:148
|
||||
msgid "Complete onboarding and start using your account"
|
||||
msgstr ""
|
||||
msgstr "Finalitza el registre i comença a utilitzar el teu compte"
|
||||
|
||||
#: src/view/com/auth/create/Step3.tsx:73
|
||||
msgid "Complete the challenge"
|
||||
@@ -819,7 +819,7 @@ msgstr "Redacta una resposta"
|
||||
|
||||
#: src/screens/Onboarding/StepModeration/ModerationOption.tsx:67
|
||||
msgid "Configure content filtering setting for category: {0}"
|
||||
msgstr ""
|
||||
msgstr "Configura els filtres de continguts per la categoria: {0}"
|
||||
|
||||
#: src/components/Prompt.tsx:124
|
||||
#: src/view/com/modals/AppealLabel.tsx:98
|
||||
@@ -914,19 +914,19 @@ msgstr "Continua"
|
||||
#: src/screens/Onboarding/StepModeration/index.tsx:115
|
||||
#: src/screens/Onboarding/StepTopicalFeeds.tsx:111
|
||||
msgid "Continue to next step"
|
||||
msgstr ""
|
||||
msgstr "Continua"
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:167
|
||||
msgid "Continue to the next step"
|
||||
msgstr ""
|
||||
msgstr "Continua"
|
||||
|
||||
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:191
|
||||
msgid "Continue to the next step without following any accounts"
|
||||
msgstr ""
|
||||
msgstr "Continua sense seguir cap compte"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:44
|
||||
msgid "Cooking"
|
||||
msgstr ""
|
||||
msgstr "Cuina"
|
||||
|
||||
#: src/view/com/modals/AddAppPasswords.tsx:195
|
||||
#: src/view/com/modals/InviteCodes.tsx:182
|
||||
@@ -1027,12 +1027,12 @@ msgstr "Crea una targeta amb una minuatura. La targeta enllaça a {url}"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:29
|
||||
msgid "Culture"
|
||||
msgstr ""
|
||||
msgstr "Cultura"
|
||||
|
||||
#: src/view/com/auth/server-input/index.tsx:95
|
||||
#: src/view/com/auth/server-input/index.tsx:96
|
||||
msgid "Custom"
|
||||
msgstr ""
|
||||
msgstr "Personalitzat"
|
||||
|
||||
#: src/view/com/modals/ChangeHandle.tsx:389
|
||||
msgid "Custom domain"
|
||||
@@ -1041,7 +1041,7 @@ msgstr "Domini personalitzat"
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:106
|
||||
#: src/view/screens/Feeds.tsx:692
|
||||
msgid "Custom feeds built by the community bring you new experiences and help you find the content you love."
|
||||
msgstr ""
|
||||
msgstr "Els canals personalitzats fets per la comunitat et porten noves experiències i t'ajuden a trobar contingut que t'agradarà."
|
||||
|
||||
#: src/view/screens/PreferencesExternalEmbeds.tsx:55
|
||||
msgid "Customize media from external sites."
|
||||
@@ -1062,7 +1062,7 @@ msgstr "Mode fosc"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:498
|
||||
msgid "Dark Theme"
|
||||
msgstr ""
|
||||
msgstr "Tema fosc"
|
||||
|
||||
#: src/view/screens/Debug.tsx:83
|
||||
msgid "Debug panel"
|
||||
@@ -1096,7 +1096,7 @@ msgstr "Elimina el meu compte"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:784
|
||||
msgid "Delete My Account…"
|
||||
msgstr ""
|
||||
msgstr "Elimina el meu compte…"
|
||||
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:317
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:326
|
||||
@@ -1136,7 +1136,7 @@ msgstr "Vols dir alguna cosa?"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:504
|
||||
msgid "Dim"
|
||||
msgstr ""
|
||||
msgstr "Tènue"
|
||||
|
||||
#: src/view/com/composer/Composer.tsx:151
|
||||
msgid "Discard"
|
||||
@@ -1161,7 +1161,7 @@ msgstr "Descobreix nous canals personalitzats"
|
||||
|
||||
#: src/view/screens/Feeds.tsx:689
|
||||
msgid "Discover New Feeds"
|
||||
msgstr ""
|
||||
msgstr "Descobreix nous canals"
|
||||
|
||||
#: src/view/com/modals/EditProfile.tsx:192
|
||||
msgid "Display name"
|
||||
@@ -1218,20 +1218,20 @@ msgstr "Fes doble toc per iniciar la sessió"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:755
|
||||
msgid "Download Bluesky account data (repository)"
|
||||
msgstr ""
|
||||
msgstr "Descarrega les dades del compte de Bluesky (repositori)"
|
||||
|
||||
#: src/view/screens/Settings/ExportCarDialog.tsx:59
|
||||
#: src/view/screens/Settings/ExportCarDialog.tsx:63
|
||||
msgid "Download CAR file"
|
||||
msgstr ""
|
||||
msgstr "Descarrega el fitxer CAR"
|
||||
|
||||
#: src/view/com/composer/text-input/TextInput.web.tsx:249
|
||||
msgid "Drop to add images"
|
||||
msgstr ""
|
||||
msgstr "Deixa anar per afegir imatges"
|
||||
|
||||
#: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:111
|
||||
msgid "Due to Apple policies, adult content can only be enabled on the web after completing sign up."
|
||||
msgstr ""
|
||||
msgstr "Degut a les polítiques d'Apple, el contingut per a adults només es pot habilitar a la web després de registrar-se"
|
||||
|
||||
#: src/view/com/modals/EditProfile.tsx:185
|
||||
msgid "e.g. Alice Roberts"
|
||||
@@ -1316,7 +1316,7 @@ msgstr "Edita la descripció del teu perfil"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:34
|
||||
msgid "Education"
|
||||
msgstr ""
|
||||
msgstr "Ensenyament"
|
||||
|
||||
#: src/view/com/auth/create/Step1.tsx:176
|
||||
#: src/view/com/auth/login/ForgotPasswordForm.tsx:156
|
||||
@@ -1357,7 +1357,7 @@ msgstr "Habilita el contingut per a adults"
|
||||
#: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:76
|
||||
#: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:77
|
||||
msgid "Enable adult content in your feeds"
|
||||
msgstr ""
|
||||
msgstr "Habilita veure el contingut per adults als teus canals"
|
||||
|
||||
#: src/view/com/modals/EmbedConsent.tsx:97
|
||||
msgid "Enable External Media"
|
||||
@@ -1394,7 +1394,7 @@ msgstr "Entra el codi de confirmació"
|
||||
|
||||
#: src/view/com/modals/ChangePassword.tsx:151
|
||||
msgid "Enter the code you received to change your password."
|
||||
msgstr ""
|
||||
msgstr "Introdueix el codi que has rebut per canviar la teva contrasenya."
|
||||
|
||||
#: src/view/com/modals/ChangeHandle.tsx:371
|
||||
msgid "Enter the domain you want to use"
|
||||
@@ -1473,12 +1473,12 @@ msgstr "Expandeix o replega la publicació completa a la qual estàs responent"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:753
|
||||
msgid "Export my data"
|
||||
msgstr ""
|
||||
msgstr "Exporta les meves dades"
|
||||
|
||||
#: src/view/screens/Settings/ExportCarDialog.tsx:44
|
||||
#: src/view/screens/Settings/index.tsx:764
|
||||
msgid "Export My Data"
|
||||
msgstr ""
|
||||
msgstr "Exporta les meves dades"
|
||||
|
||||
#: src/view/com/modals/EmbedConsent.tsx:64
|
||||
msgid "External Media"
|
||||
@@ -1559,11 +1559,11 @@ msgstr "Els canals són algoritmes personalitzats creats per usuaris que coneixe
|
||||
|
||||
#: src/screens/Onboarding/StepTopicalFeeds.tsx:76
|
||||
msgid "Feeds can be topical as well!"
|
||||
msgstr ""
|
||||
msgstr "Els canals també poden ser d'actualitat!"
|
||||
|
||||
#: src/screens/Onboarding/StepFinished.tsx:151
|
||||
msgid "Finalizing"
|
||||
msgstr ""
|
||||
msgstr "Finalitzant"
|
||||
|
||||
#: src/view/com/posts/CustomFeedEmptyState.tsx:47
|
||||
#: src/view/com/posts/FollowingEmptyState.tsx:57
|
||||
@@ -1597,11 +1597,11 @@ msgstr "Ajusta els fils de debat."
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:38
|
||||
msgid "Fitness"
|
||||
msgstr ""
|
||||
msgstr "Exercici"
|
||||
|
||||
#: src/screens/Onboarding/StepFinished.tsx:131
|
||||
msgid "Flexible"
|
||||
msgstr ""
|
||||
msgstr "Flexible"
|
||||
|
||||
#: src/view/com/modals/EditImage.tsx:115
|
||||
msgid "Flip horizontal"
|
||||
@@ -1631,11 +1631,11 @@ msgstr "Segueix {0}"
|
||||
|
||||
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:179
|
||||
msgid "Follow All"
|
||||
msgstr ""
|
||||
msgstr "Segueix-los a tots"
|
||||
|
||||
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:174
|
||||
msgid "Follow selected accounts and continue to the next step"
|
||||
msgstr ""
|
||||
msgstr "Segueix els comptes seleccionats i continua"
|
||||
|
||||
#: src/view/com/auth/onboarding/RecommendedFollows.tsx:64
|
||||
msgid "Follow some users to get started. We can recommend you more users based on who you find interesting."
|
||||
@@ -1693,7 +1693,7 @@ msgstr "Et segueix"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:43
|
||||
msgid "Food"
|
||||
msgstr ""
|
||||
msgstr "Menjar"
|
||||
|
||||
#: src/view/com/modals/DeleteAccount.tsx:111
|
||||
msgid "For security reasons, we'll need to send a confirmation code to your email address."
|
||||
@@ -1752,7 +1752,7 @@ msgstr "Ves enrere"
|
||||
#: src/screens/Onboarding/Layout.tsx:104
|
||||
#: src/screens/Onboarding/Layout.tsx:193
|
||||
msgid "Go back to previous step"
|
||||
msgstr ""
|
||||
msgstr "Ves al pas anterior"
|
||||
|
||||
#: src/view/screens/Search/Search.tsx:747
|
||||
#: src/view/shell/desktop/Search.tsx:262
|
||||
@@ -1794,15 +1794,15 @@ msgstr "Ajuda"
|
||||
|
||||
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:132
|
||||
msgid "Here are some accounts for you to follow"
|
||||
msgstr ""
|
||||
msgstr "Aquí tens uns quants comptes que pots seguir"
|
||||
|
||||
#: src/screens/Onboarding/StepTopicalFeeds.tsx:85
|
||||
msgid "Here are some popular topical feeds. You can choose to follow as many as you like."
|
||||
msgstr ""
|
||||
msgstr "Aquí tens alguns canals d'actualitat populars. Pots seguir-ne tants com vulguis."
|
||||
|
||||
#: src/screens/Onboarding/StepTopicalFeeds.tsx:80
|
||||
msgid "Here are some topical feeds based on your interests: {interestsText}. You can choose to follow as many as you like."
|
||||
msgstr ""
|
||||
msgstr "Aquí tens uns quants canals d'actualitat basats en els teus interesos: {interestsText}. Pots seguir-ne tants com vulguis."
|
||||
|
||||
#: src/view/com/modals/AddAppPasswords.tsx:153
|
||||
msgid "Here is your app password."
|
||||
@@ -1914,7 +1914,7 @@ msgstr "Si no en selecciones cap, és apropiat per a totes les edats."
|
||||
|
||||
#: src/view/com/modals/ChangePassword.tsx:146
|
||||
msgid "If you want to change your password, we will send you a code to verify that this is your account."
|
||||
msgstr ""
|
||||
msgstr "Si vols canviar la contrasenya t'enviarem un codi per verificar que aquest compte és teu."
|
||||
|
||||
#: src/view/com/util/images/Gallery.tsx:38
|
||||
msgid "Image"
|
||||
@@ -2024,7 +2024,7 @@ msgstr "Codis d'invitació: 1 disponible"
|
||||
|
||||
#: src/screens/Onboarding/StepFollowingFeed.tsx:64
|
||||
msgid "It shows posts from the people you follow as they happen."
|
||||
msgstr ""
|
||||
msgstr "Mostra les publicacions de les persones que segueixes cronològicament."
|
||||
|
||||
#: src/view/com/auth/HomeLoggedOutCTA.tsx:99
|
||||
#: src/view/com/auth/SplashScreen.web.tsx:138
|
||||
@@ -2046,7 +2046,7 @@ msgstr "Feines"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:24
|
||||
msgid "Journalism"
|
||||
msgstr ""
|
||||
msgstr "Periodisme"
|
||||
|
||||
#: src/view/com/composer/select-language/SelectLangBtn.tsx:104
|
||||
msgid "Language selection"
|
||||
@@ -2101,7 +2101,7 @@ msgstr "Sortint de Bluesky"
|
||||
|
||||
#: src/screens/Deactivated.tsx:128
|
||||
msgid "left to go."
|
||||
msgstr ""
|
||||
msgstr "queda."
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:278
|
||||
msgid "Legacy storage cleared, you need to restart the app now."
|
||||
@@ -2114,7 +2114,7 @@ msgstr "Restablirem la teva contrasenya!"
|
||||
|
||||
#: src/screens/Onboarding/StepFinished.tsx:151
|
||||
msgid "Let's go!"
|
||||
msgstr ""
|
||||
msgstr "Som-hi!"
|
||||
|
||||
#: src/view/com/util/UserAvatar.tsx:248
|
||||
#: src/view/com/util/UserBanner.tsx:62
|
||||
@@ -2140,7 +2140,7 @@ msgstr "Li ha agradat a"
|
||||
#: src/view/screens/PostLikedBy.tsx:27
|
||||
#: src/view/screens/ProfileFeedLikedBy.tsx:27
|
||||
msgid "Liked By"
|
||||
msgstr ""
|
||||
msgstr "Li ha agradat a"
|
||||
|
||||
#: src/view/com/feeds/FeedSourceCard.tsx:279
|
||||
msgid "Liked by {0} {1}"
|
||||
@@ -2152,7 +2152,7 @@ msgstr "Li ha agradat a {likeCount} {0}"
|
||||
|
||||
#: src/view/com/notifications/FeedItem.tsx:170
|
||||
msgid "liked your custom feed"
|
||||
msgstr ""
|
||||
msgstr "els hi ha agradat el teu canal personalitzat"
|
||||
|
||||
#: src/view/com/notifications/FeedItem.tsx:171
|
||||
#~ msgid "liked your custom feed{0}"
|
||||
@@ -2247,7 +2247,7 @@ msgstr "Registre"
|
||||
#: src/screens/Deactivated.tsx:178
|
||||
#: src/screens/Deactivated.tsx:181
|
||||
msgid "Log out"
|
||||
msgstr ""
|
||||
msgstr "Desconnecta"
|
||||
|
||||
#: src/view/screens/Moderation.tsx:155
|
||||
msgid "Logged-out visibility"
|
||||
@@ -2477,7 +2477,7 @@ msgstr "Els meus canals desats"
|
||||
|
||||
#: src/view/com/auth/server-input/index.tsx:118
|
||||
msgid "my-server.com"
|
||||
msgstr ""
|
||||
msgstr "el-meu-servidor.com"
|
||||
|
||||
#: src/view/com/modals/AddAppPasswords.tsx:179
|
||||
#: src/view/com/modals/CreateOrEditList.tsx:290
|
||||
@@ -2490,7 +2490,7 @@ msgstr "Es requereix un nom"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:25
|
||||
msgid "Nature"
|
||||
msgstr ""
|
||||
msgstr "Natura"
|
||||
|
||||
#: src/view/com/auth/login/ForgotPasswordForm.tsx:190
|
||||
#: src/view/com/auth/login/ForgotPasswordForm.tsx:219
|
||||
@@ -2516,7 +2516,7 @@ msgstr "No perdis mai accés als teus seguidors ni a les teves dades."
|
||||
|
||||
#: src/screens/Onboarding/StepFinished.tsx:119
|
||||
msgid "Never lose access to your followers or data."
|
||||
msgstr ""
|
||||
msgstr "No perdis mai accés als teus seguidors i les teves dades."
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:293
|
||||
msgid "Nevermind"
|
||||
@@ -2541,7 +2541,7 @@ msgstr "Nova contrasenya"
|
||||
|
||||
#: src/view/com/modals/ChangePassword.tsx:215
|
||||
msgid "New Password"
|
||||
msgstr ""
|
||||
msgstr "Nova contrasenya"
|
||||
|
||||
#: src/view/com/feeds/FeedPage.tsx:126
|
||||
msgctxt "action"
|
||||
@@ -2577,7 +2577,7 @@ msgstr "Les respostes més noves primer"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:23
|
||||
msgid "News"
|
||||
msgstr ""
|
||||
msgstr "Notícies"
|
||||
|
||||
#: src/view/com/auth/create/CreateAccount.tsx:172
|
||||
#: src/view/com/auth/login/ForgotPasswordForm.tsx:182
|
||||
@@ -2687,7 +2687,7 @@ msgstr "Ostres!"
|
||||
|
||||
#: src/screens/Onboarding/StepInterests/index.tsx:128
|
||||
msgid "Oh no! Something went wrong."
|
||||
msgstr ""
|
||||
msgstr "Ostres! Alguna cosa ha fallat."
|
||||
|
||||
#: src/view/com/auth/login/PasswordUpdatedForm.tsx:41
|
||||
msgid "Okay"
|
||||
@@ -2721,7 +2721,7 @@ msgstr "Ostres!"
|
||||
|
||||
#: src/screens/Onboarding/StepFinished.tsx:115
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
msgstr "Obre"
|
||||
|
||||
#: src/view/screens/Moderation.tsx:75
|
||||
msgid "Open content filtering settings"
|
||||
@@ -2750,7 +2750,7 @@ msgstr ""
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:804
|
||||
msgid "Open storybook page"
|
||||
msgstr ""
|
||||
msgstr "Obre la pàgina d'historial"
|
||||
|
||||
#: src/view/com/util/forms/DropdownButton.tsx:154
|
||||
msgid "Opens {numItems} options"
|
||||
@@ -2876,7 +2876,7 @@ msgstr "Pàgina no trobada"
|
||||
|
||||
#: src/view/screens/NotFound.tsx:42
|
||||
msgid "Page Not Found"
|
||||
msgstr ""
|
||||
msgstr "Pàgina no trobada"
|
||||
|
||||
#: src/view/com/auth/create/Step1.tsx:191
|
||||
#: src/view/com/auth/create/Step1.tsx:201
|
||||
@@ -2912,7 +2912,7 @@ msgstr "S'ha denegat el permís per accedir a la càmera. Activa'l a la configur
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:31
|
||||
msgid "Pets"
|
||||
msgstr ""
|
||||
msgstr "Mascotes"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:183
|
||||
#~ msgid "Phone number"
|
||||
@@ -3010,7 +3010,7 @@ msgstr "Espera que es generi la targeta de l'enllaç"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:37
|
||||
msgid "Politics"
|
||||
msgstr ""
|
||||
msgstr "Política"
|
||||
|
||||
#: src/view/com/modals/SelfLabel.tsx:111
|
||||
msgid "Porn"
|
||||
@@ -3129,7 +3129,7 @@ msgstr "Protegeix el teu compte verificant el teu correu."
|
||||
|
||||
#: src/screens/Onboarding/StepFinished.tsx:101
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
msgstr "Públic"
|
||||
|
||||
#: src/view/screens/ModerationModlists.tsx:61
|
||||
msgid "Public, shareable lists of users to mute or block in bulk."
|
||||
@@ -3300,7 +3300,7 @@ msgstr "Informa de la publicació"
|
||||
#: src/view/com/util/post-ctrls/RepostButton.tsx:61
|
||||
msgctxt "action"
|
||||
msgid "Repost"
|
||||
msgstr "Respon"
|
||||
msgstr "Republica"
|
||||
|
||||
#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48
|
||||
msgid "Repost"
|
||||
@@ -3317,11 +3317,11 @@ msgstr "Republica o cita la publicació"
|
||||
|
||||
#: src/view/screens/PostRepostedBy.tsx:27
|
||||
msgid "Reposted By"
|
||||
msgstr ""
|
||||
msgstr "Republicat per"
|
||||
|
||||
#: src/view/com/posts/FeedItem.tsx:207
|
||||
msgid "Reposted by {0}"
|
||||
msgstr ""
|
||||
msgstr "Republicat per {0}"
|
||||
|
||||
#: src/view/com/posts/FeedItem.tsx:206
|
||||
#~ msgid "Reposted by {0})"
|
||||
@@ -3351,7 +3351,7 @@ msgstr "Demana un canvi"
|
||||
#: src/view/com/modals/ChangePassword.tsx:239
|
||||
#: src/view/com/modals/ChangePassword.tsx:241
|
||||
msgid "Request Code"
|
||||
msgstr ""
|
||||
msgstr "Demana un codi"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:456
|
||||
msgid "Require alt text before posting"
|
||||
@@ -3368,7 +3368,7 @@ msgstr "Codi de restabliment"
|
||||
|
||||
#: src/view/com/modals/ChangePassword.tsx:190
|
||||
msgid "Reset Code"
|
||||
msgstr ""
|
||||
msgstr "Codi de restabliment"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:824
|
||||
msgid "Reset onboarding"
|
||||
@@ -3475,7 +3475,7 @@ msgstr "Desa el canvi d'identificador a {handle}"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:36
|
||||
msgid "Science"
|
||||
msgstr ""
|
||||
msgstr "Ciència"
|
||||
|
||||
#: src/view/screens/ProfileList.tsx:859
|
||||
msgid "Scroll to top"
|
||||
@@ -3584,19 +3584,19 @@ msgstr "Selecciona el servei"
|
||||
|
||||
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:52
|
||||
msgid "Select some accounts below to follow"
|
||||
msgstr ""
|
||||
msgstr "Selecciona alguns d'aquests comptes per seguir-los"
|
||||
|
||||
#: src/view/com/auth/server-input/index.tsx:82
|
||||
msgid "Select the service that hosts your data."
|
||||
msgstr ""
|
||||
msgstr "Selecciona el servei que allotja les teves dades."
|
||||
|
||||
#: src/screens/Onboarding/StepTopicalFeeds.tsx:96
|
||||
msgid "Select topical feeds to follow from the list below"
|
||||
msgstr ""
|
||||
msgstr "Selecciona els canals d'actualitat per seguir d'aquesta llista"
|
||||
|
||||
#: src/screens/Onboarding/StepModeration/index.tsx:75
|
||||
msgid "Select what you want to see (or not see), and we’ll handle the rest."
|
||||
msgstr ""
|
||||
msgstr "Selecciona què vols veure (o què no vols veure) i nosaltres farem la resta."
|
||||
|
||||
#: src/view/screens/LanguageSettings.tsx:281
|
||||
msgid "Select which languages you want your subscribed feeds to include. If none are selected, all languages will be shown."
|
||||
@@ -3608,7 +3608,7 @@ msgstr "Selecciona l'idioma de l'aplicació perquè el text predeterminat es mos
|
||||
|
||||
#: src/screens/Onboarding/StepInterests/index.tsx:196
|
||||
msgid "Select your interests from the options below"
|
||||
msgstr ""
|
||||
msgstr "Selecciona els teus interesos d'entre aquestes opcions"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:155
|
||||
#~ msgid "Select your phone's country"
|
||||
@@ -3620,11 +3620,11 @@ msgstr "Selecciona el teu idioma preferit per a les traduccions al teu canal."
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:116
|
||||
msgid "Select your primary algorithmic feeds"
|
||||
msgstr ""
|
||||
msgstr "Selecciona els teus canals algorítmics primaris"
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:142
|
||||
msgid "Select your secondary algorithmic feeds"
|
||||
msgstr ""
|
||||
msgstr "Selecciona els teus canals algorítmics secundaris"
|
||||
|
||||
#: src/view/com/modals/VerifyEmail.tsx:202
|
||||
#: src/view/com/modals/VerifyEmail.tsx:204
|
||||
@@ -3659,7 +3659,7 @@ msgstr "Envia un correu amb el codi de confirmació per l'eliminació del compte
|
||||
|
||||
#: src/view/com/auth/server-input/index.tsx:110
|
||||
msgid "Server address"
|
||||
msgstr ""
|
||||
msgstr "Adreça del servidor"
|
||||
|
||||
#: src/view/com/modals/ContentFilteringSettings.tsx:311
|
||||
msgid "Set {value} for {labelGroup} content moderation policy"
|
||||
@@ -3685,11 +3685,11 @@ msgstr "Estableix el tema de colors a la configuració del sistema"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:514
|
||||
msgid "Set dark theme to the dark theme"
|
||||
msgstr ""
|
||||
msgstr "Posa el tema fosc"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:507
|
||||
msgid "Set dark theme to the dim theme"
|
||||
msgstr ""
|
||||
msgstr "Posa el tema fosc al tema atenuat"
|
||||
|
||||
#: src/view/com/auth/login/SetNewPasswordForm.tsx:104
|
||||
msgid "Set new password"
|
||||
@@ -3725,7 +3725,7 @@ msgstr ""
|
||||
|
||||
#: src/screens/Onboarding/Layout.tsx:50
|
||||
msgid "Set up your account"
|
||||
msgstr ""
|
||||
msgstr "Configura el teu compte"
|
||||
|
||||
#: src/view/com/modals/ChangeHandle.tsx:266
|
||||
msgid "Sets Bluesky username"
|
||||
@@ -3813,15 +3813,15 @@ msgstr "Mostra les publicacions citades"
|
||||
|
||||
#: src/screens/Onboarding/StepFollowingFeed.tsx:118
|
||||
msgid "Show quote-posts in Following feed"
|
||||
msgstr ""
|
||||
msgstr "Mostra les publicacions citades en el canal Seguint"
|
||||
|
||||
#: src/screens/Onboarding/StepFollowingFeed.tsx:134
|
||||
msgid "Show quotes in Following"
|
||||
msgstr ""
|
||||
msgstr "Mostra els citats a Seguint"
|
||||
|
||||
#: src/screens/Onboarding/StepFollowingFeed.tsx:94
|
||||
msgid "Show re-posts in Following feed"
|
||||
msgstr ""
|
||||
msgstr "Mostra les republicacions al canal Seguint"
|
||||
|
||||
#: src/view/screens/PreferencesFollowingFeed.tsx:119
|
||||
msgid "Show Replies"
|
||||
@@ -3833,11 +3833,11 @@ msgstr "Mostra les respostes dels comptes que segueixes abans que les altres."
|
||||
|
||||
#: src/screens/Onboarding/StepFollowingFeed.tsx:86
|
||||
msgid "Show replies in Following"
|
||||
msgstr ""
|
||||
msgstr "Mostra les respostes a Seguint"
|
||||
|
||||
#: src/screens/Onboarding/StepFollowingFeed.tsx:70
|
||||
msgid "Show replies in Following feed"
|
||||
msgstr ""
|
||||
msgstr "Mostrea les respostes al canal Seguint"
|
||||
|
||||
#: src/view/screens/PreferencesFollowingFeed.tsx:70
|
||||
msgid "Show replies with at least {value} {0}"
|
||||
@@ -3849,7 +3849,7 @@ msgstr "Mostra republicacions"
|
||||
|
||||
#: src/screens/Onboarding/StepFollowingFeed.tsx:110
|
||||
msgid "Show reposts in Following"
|
||||
msgstr ""
|
||||
msgstr "Mostra les republicacions al canal Seguint"
|
||||
|
||||
#: src/view/com/util/moderation/ContentHider.tsx:67
|
||||
#: src/view/com/util/moderation/PostHider.tsx:61
|
||||
@@ -3949,7 +3949,7 @@ msgstr "Salta aquest pas"
|
||||
|
||||
#: src/screens/Onboarding/StepInterests/index.tsx:232
|
||||
msgid "Skip this flow"
|
||||
msgstr ""
|
||||
msgstr "Salta aquest flux"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:82
|
||||
#~ msgid "SMS verification"
|
||||
@@ -3957,7 +3957,7 @@ msgstr ""
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:40
|
||||
msgid "Software Dev"
|
||||
msgstr ""
|
||||
msgstr "Desenvolupament de programari"
|
||||
|
||||
#: src/view/com/modals/ProfilePreview.tsx:62
|
||||
#~ msgid "Something went wrong and we're not sure what."
|
||||
@@ -3985,7 +3985,7 @@ msgstr "Ordena les respostes a la mateixa publicació per:"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:30
|
||||
msgid "Sports"
|
||||
msgstr ""
|
||||
msgstr "Esports"
|
||||
|
||||
#: src/view/com/modals/crop-image/CropImage.web.tsx:122
|
||||
msgid "Square"
|
||||
@@ -4023,7 +4023,7 @@ msgstr "Subscriure's"
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx:173
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx:308
|
||||
msgid "Subscribe to the {0} feed"
|
||||
msgstr ""
|
||||
msgstr "Subscriu-te al canal {0}"
|
||||
|
||||
#: src/view/screens/ProfileList.tsx:604
|
||||
msgid "Subscribe to this list"
|
||||
@@ -4095,7 +4095,7 @@ msgstr "Toca per veure-ho completament"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:39
|
||||
msgid "Tech"
|
||||
msgstr ""
|
||||
msgstr "Tecnologia"
|
||||
|
||||
#: src/view/shell/desktop/RightNav.tsx:81
|
||||
msgid "Terms"
|
||||
@@ -4135,7 +4135,7 @@ msgstr "La política de drets d'autoria ha estat traslladada a <0/>"
|
||||
|
||||
#: src/screens/Onboarding/Layout.tsx:60
|
||||
msgid "The following steps will help customize your Bluesky experience."
|
||||
msgstr ""
|
||||
msgstr "Els següents passos t'ajudaran a personalitzar la teva experiència a Bluesky."
|
||||
|
||||
#: src/view/com/post-thread/PostThread.tsx:517
|
||||
msgid "The post may have been deleted."
|
||||
@@ -4159,7 +4159,7 @@ msgstr "Les condicions del servei han estat traslladades a "
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:150
|
||||
msgid "There are many feeds to try:"
|
||||
msgstr ""
|
||||
msgstr "Hi ha molts canals per provar:"
|
||||
|
||||
#: src/view/screens/ProfileFeed.tsx:550
|
||||
msgid "There was an an issue contacting the server, please check your internet connection and try again."
|
||||
@@ -4239,7 +4239,7 @@ msgstr "S'ha produït un problema inesperat a l'aplicació. Fes-nos saber si aix
|
||||
|
||||
#: src/screens/Deactivated.tsx:106
|
||||
msgid "There's been a rush of new users to Bluesky! We'll activate your account as soon as we can."
|
||||
msgstr ""
|
||||
msgstr "Hi ha hagut una gran quantitat d'usuaris nous a Bluesky! Activarem el teu compte tan aviat com puguem."
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:55
|
||||
#~ msgid "There's something wrong with this number. Please choose your country and enter your full phone number!"
|
||||
@@ -4247,7 +4247,7 @@ msgstr ""
|
||||
|
||||
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:138
|
||||
msgid "These are popular accounts you might like:"
|
||||
msgstr ""
|
||||
msgstr "Aquests són alguns comptes populars que et poden agradar:"
|
||||
|
||||
#~ msgid "This {0} has been labeled."
|
||||
#~ msgstr "Aquest {0} ha estat etiquetat."
|
||||
@@ -4274,7 +4274,7 @@ msgstr "Aquest contingut no es pot veure sense un compte de Bluesky."
|
||||
|
||||
#: src/view/screens/Settings/ExportCarDialog.tsx:75
|
||||
msgid "This feature is in beta. You can read more about repository exports in <0>this blogpost.</0>"
|
||||
msgstr ""
|
||||
msgstr "Aquesta funcionalitat està en beta. En <0>aquesta entrada al blog</0> tens més informació."
|
||||
|
||||
#: src/view/com/posts/FeedErrorMessage.tsx:114
|
||||
msgid "This feed is currently receiving high traffic and is temporarily unavailable. Please try again later."
|
||||
@@ -4328,7 +4328,7 @@ msgstr "Aquest usuari està inclós a la llista <0/> que tens bloquejada"
|
||||
|
||||
#: src/view/com/modals/ModerationDetails.tsx:74
|
||||
msgid "This user is included in the <0/> list which you have muted."
|
||||
msgstr ""
|
||||
msgstr "Aquest usuari està inclòs a la llista <0/> que has silenciat."
|
||||
|
||||
#: src/view/com/modals/ModerationDetails.tsx:74
|
||||
#~ msgid "This user is included the <0/> list which you have muted."
|
||||
@@ -4614,7 +4614,7 @@ msgstr "Verifica el teu correu"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:42
|
||||
msgid "Video Games"
|
||||
msgstr ""
|
||||
msgstr "Videojocs"
|
||||
|
||||
#: src/view/com/profile/ProfileHeader.tsx:662
|
||||
msgid "View {0}'s avatar"
|
||||
@@ -4647,7 +4647,7 @@ msgstr "Adverteix"
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:134
|
||||
msgid "We also think you'll like \"For You\" by Skygaze:"
|
||||
msgstr ""
|
||||
msgstr "També creiem que t'agradarà el canal \"For You\" d'Skygaze:"
|
||||
|
||||
#: src/screens/Hashtag.tsx:132
|
||||
msgid "We couldn't find any results for that hashtag."
|
||||
@@ -4655,11 +4655,11 @@ msgstr ""
|
||||
|
||||
#: src/screens/Deactivated.tsx:133
|
||||
msgid "We estimate {estimatedTime} until your account is ready."
|
||||
msgstr ""
|
||||
msgstr "Calculem {estimatedTime} fins que el teu compte estigui llest."
|
||||
|
||||
#: src/screens/Onboarding/StepFinished.tsx:93
|
||||
msgid "We hope you have a wonderful time. Remember, Bluesky is:"
|
||||
msgstr ""
|
||||
msgstr "Esperem que t'ho passis pipa. Recorda que Bluesky és:"
|
||||
|
||||
#: src/view/com/posts/DiscoverFallbackHeader.tsx:29
|
||||
msgid "We ran out of posts from your follows. Here's the latest from <0/>."
|
||||
@@ -4671,15 +4671,15 @@ msgstr ""
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:124
|
||||
msgid "We recommend our \"Discover\" feed:"
|
||||
msgstr ""
|
||||
msgstr "Et reomanem el nostre canal \"Discover\":"
|
||||
|
||||
#: src/screens/Onboarding/StepInterests/index.tsx:133
|
||||
msgid "We weren't able to connect. Please try again to continue setting up your account. If it continues to fail, you can skip this flow."
|
||||
msgstr ""
|
||||
msgstr "No ens hem pogut connectar. Torna-ho a provar per continuar configurant el teu compte. Si continua fallant, pots ometre aquest flux."
|
||||
|
||||
#: src/screens/Deactivated.tsx:137
|
||||
msgid "We will let you know when your account is ready."
|
||||
msgstr ""
|
||||
msgstr "T'informarem quan el teu compte estigui llest."
|
||||
|
||||
#: src/view/com/modals/AppealLabel.tsx:48
|
||||
msgid "We'll look into your appeal promptly."
|
||||
@@ -4687,7 +4687,7 @@ msgstr "Analitzarem la teva apel·lació ràpidament."
|
||||
|
||||
#: src/screens/Onboarding/StepInterests/index.tsx:138
|
||||
msgid "We'll use this to help customize your experience."
|
||||
msgstr ""
|
||||
msgstr "Ho farem servir per personalitzar la teva experiència."
|
||||
|
||||
#: src/view/com/auth/create/CreateAccount.tsx:134
|
||||
msgid "We're so excited to have you join us!"
|
||||
@@ -4716,7 +4716,7 @@ msgstr "Benvingut a <0>Bluesky</0>"
|
||||
|
||||
#: src/screens/Onboarding/StepInterests/index.tsx:130
|
||||
msgid "What are your interests?"
|
||||
msgstr ""
|
||||
msgstr "Quins són els teus interesos?"
|
||||
|
||||
#: src/view/com/modals/report/Modal.tsx:169
|
||||
msgid "What is the issue with this {collectionName}?"
|
||||
@@ -4758,7 +4758,7 @@ msgstr "Escriu la teva resposta"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:28
|
||||
msgid "Writers"
|
||||
msgstr ""
|
||||
msgstr "Escriptors"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:263
|
||||
#~ msgid "XXXXXX"
|
||||
@@ -4776,7 +4776,7 @@ msgstr "Sí"
|
||||
|
||||
#: src/screens/Deactivated.tsx:130
|
||||
msgid "You are in line."
|
||||
msgstr ""
|
||||
msgstr "Estàs a la cua."
|
||||
|
||||
#: src/view/com/posts/FollowingEmptyState.tsx:67
|
||||
#: src/view/com/posts/FollowingEndOfFeed.tsx:68
|
||||
@@ -4789,7 +4789,7 @@ msgstr "També pots descobrir nous canals personalitzats per seguir."
|
||||
|
||||
#: src/screens/Onboarding/StepFollowingFeed.tsx:142
|
||||
msgid "You can change these settings later."
|
||||
msgstr ""
|
||||
msgstr "Pots canviar aquests paràmetres més endavant."
|
||||
|
||||
#: src/view/com/auth/login/Login.tsx:158
|
||||
#: src/view/com/auth/login/PasswordUpdatedForm.tsx:31
|
||||
@@ -4825,7 +4825,7 @@ msgstr "Has bloquejat aquest usuari. No pots veure el seu contingut."
|
||||
#: src/view/com/modals/ChangePassword.tsx:87
|
||||
#: src/view/com/modals/ChangePassword.tsx:121
|
||||
msgid "You have entered an invalid code. It should look like XXXXX-XXXXX."
|
||||
msgstr ""
|
||||
msgstr "Has entrat un codi invàlid. Hauria de ser tipus XXXXX-XXXXX."
|
||||
|
||||
#: src/view/com/modals/ModerationDetails.tsx:87
|
||||
msgid "You have muted this user."
|
||||
@@ -4862,7 +4862,7 @@ msgstr "Has de tenir 18 anys o més per habilitar el contingut per a adults."
|
||||
|
||||
#: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:103
|
||||
msgid "You must be 18 years or older to enable adult content"
|
||||
msgstr ""
|
||||
msgstr "Has de tenir 18 anys o més per habilitar el contingut per a adults"
|
||||
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:147
|
||||
msgid "You will no longer receive notifications for this thread"
|
||||
@@ -4878,17 +4878,17 @@ msgstr "Rebràs un correu amb un \"codi de restabliment\". Introdueix aquí el c
|
||||
|
||||
#: src/screens/Onboarding/StepModeration/index.tsx:72
|
||||
msgid "You're in control"
|
||||
msgstr ""
|
||||
msgstr "Tu tens el control"
|
||||
|
||||
#: src/screens/Deactivated.tsx:87
|
||||
#: src/screens/Deactivated.tsx:88
|
||||
#: src/screens/Deactivated.tsx:103
|
||||
msgid "You're in line"
|
||||
msgstr ""
|
||||
msgstr "Estàs a la cua"
|
||||
|
||||
#: src/screens/Onboarding/StepFinished.tsx:90
|
||||
msgid "You're ready to go!"
|
||||
msgstr ""
|
||||
msgstr "Ja està tot llest!"
|
||||
|
||||
#: src/view/com/posts/FollowingEndOfFeed.tsx:48
|
||||
msgid "You've reached the end of your feed! Find some more accounts to follow."
|
||||
@@ -4904,7 +4904,7 @@ msgstr "El teu compte s'ha eliminat"
|
||||
|
||||
#: src/view/screens/Settings/ExportCarDialog.tsx:47
|
||||
msgid "Your account repository, containing all public data records, can be downloaded as a \"CAR\" file. This file does not include media embeds, such as images, or your private data, which must be fetched separately."
|
||||
msgstr ""
|
||||
msgstr "El repositori del teu compte, que conté tots els registres de dades públiques, es pot baixar com a fitxer \"CAR\". Aquest fitxer no inclou incrustacions multimèdia, com ara imatges, ni les teves dades privades, que s'han d'obtenir per separat."
|
||||
|
||||
#: src/view/com/auth/create/Step1.tsx:215
|
||||
msgid "Your birth date"
|
||||
@@ -4916,7 +4916,7 @@ msgstr "La teva elecció es desarà, però es pot canviar més endavant a la con
|
||||
|
||||
#: src/screens/Onboarding/StepFollowingFeed.tsx:61
|
||||
msgid "Your default feed is \"Following\""
|
||||
msgstr ""
|
||||
msgstr "El teu canal per defecte és \"Seguint\""
|
||||
|
||||
#: src/view/com/auth/create/state.ts:110
|
||||
#: src/view/com/auth/login/ForgotPasswordForm.tsx:70
|
||||
@@ -4964,7 +4964,7 @@ msgstr ""
|
||||
|
||||
#: src/view/com/modals/ChangePassword.tsx:155
|
||||
msgid "Your password has been changed successfully!"
|
||||
msgstr ""
|
||||
msgstr "S'ha canviat la teva contrasenya!"
|
||||
|
||||
#: src/view/com/composer/Composer.tsx:274
|
||||
msgid "Your post has been published"
|
||||
|
||||
+585
-881
File diff suppressed because it is too large
Load Diff
@@ -4395,8 +4395,8 @@ msgstr ""
|
||||
msgid "Unmute thread"
|
||||
msgstr ""
|
||||
|
||||
#: src/view/screens/ProfileFeed.tsx:354
|
||||
#: src/view/screens/ProfileList.tsx:581
|
||||
#: src/view/screens/ProfileFeed.tsx:353
|
||||
#: src/view/screens/ProfileList.tsx:580
|
||||
msgid "Unpin"
|
||||
msgstr ""
|
||||
|
||||
@@ -4915,4 +4915,4 @@ msgstr ""
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:65
|
||||
msgid "Your user handle"
|
||||
msgstr ""
|
||||
msgstr ""
|
||||
File diff suppressed because it is too large
Load Diff
+274
-574
File diff suppressed because it is too large
Load Diff
+342
-357
File diff suppressed because it is too large
Load Diff
+1348
-1065
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Language: pt-BR\n"
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2024-02-08 19:59\n"
|
||||
"Last-Translator: maisondasilva\n"
|
||||
"PO-Revision-Date: 2024-03-12 11:36\n"
|
||||
"Last-Translator: gildaswise\n"
|
||||
"Language-Team: maisondasilva, MightyLoggor, gildaswise, gleydson, faeriarum\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
@@ -17,28 +17,10 @@ msgstr ""
|
||||
msgid "(no email)"
|
||||
msgstr "(sem email)"
|
||||
|
||||
#: src/view/shell/desktop/RightNav.tsx:168
|
||||
#~ msgid "{0, plural, one {# invite code available} other {# invite codes available}}"
|
||||
#~ msgstr "{0, plural, one {# convite disponível} other {# convites disponíveis}}"
|
||||
|
||||
#: src/view/com/profile/ProfileHeader.tsx:593
|
||||
msgid "{following} following"
|
||||
msgstr "{following} seguindo"
|
||||
|
||||
#: src/view/shell/desktop/RightNav.tsx:151
|
||||
#~ msgid "{invitesAvailable, plural, one {Invite codes: # available} other {Invite codes: # available}}"
|
||||
#~ msgstr "{invitesAvailable, plural, one {Convites: # disponível} other {Convites: # disponíveis}}"
|
||||
|
||||
#: src/view/screens/Settings.tsx:435
|
||||
#: src/view/shell/Drawer.tsx:664
|
||||
#~ msgid "{invitesAvailable} invite code available"
|
||||
#~ msgstr "{invitesAvailable} convite disponível"
|
||||
|
||||
#: src/view/screens/Settings.tsx:437
|
||||
#: src/view/shell/Drawer.tsx:666
|
||||
#~ msgid "{invitesAvailable} invite codes available"
|
||||
#~ msgstr "{invitesAvailable} convites disponíveis"
|
||||
|
||||
#: src/view/shell/Drawer.tsx:440
|
||||
msgid "{numUnreadNotifications} unread"
|
||||
msgstr "{numUnreadNotifications} não lidas"
|
||||
@@ -179,11 +161,11 @@ msgstr "Adicionar prévia de link:"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:158
|
||||
msgid "Add mute word for configured settings"
|
||||
msgstr ""
|
||||
msgstr "Adicionar palavra silenciada para as configurações selecionadas"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:87
|
||||
msgid "Add muted words and tags"
|
||||
msgstr ""
|
||||
msgstr "Adicionar palavras/tags silenciadas"
|
||||
|
||||
#: src/view/com/modals/ChangeHandle.tsx:417
|
||||
msgid "Add the following DNS record to your domain:"
|
||||
@@ -223,17 +205,13 @@ msgstr "Conteúdo Adulto"
|
||||
msgid "Adult content can only be enabled via the Web at <0/>."
|
||||
msgstr "Conteúdo adulto só pode ser habilitado no site: <0/>."
|
||||
|
||||
#: src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx:78
|
||||
#~ msgid "Adult content can only be enabled via the Web at <0>bsky.app</0>."
|
||||
#~ msgstr "Conteúdo adulto só pode ser habilitado no site: <0>bsky.app</0>."
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:664
|
||||
msgid "Advanced"
|
||||
msgstr "Avançado"
|
||||
|
||||
#: src/view/screens/Feeds.tsx:666
|
||||
msgid "All the feeds you've saved, right in one place."
|
||||
msgstr ""
|
||||
msgstr "Todos os feeds que você salvou, em um único lugar."
|
||||
|
||||
#: src/view/com/auth/login/ForgotPasswordForm.tsx:221
|
||||
#: src/view/com/modals/ChangePassword.tsx:168
|
||||
@@ -458,7 +436,7 @@ msgstr "Bluesky"
|
||||
|
||||
#: src/view/com/auth/server-input/index.tsx:150
|
||||
msgid "Bluesky is an open network where you can choose your hosting provider. Custom hosting is now available in beta for developers."
|
||||
msgstr ""
|
||||
msgstr "Bluesky é uma rede aberta que permite a escolha do seu provedor de hospedagem. Desenvolvedores já conseguem utilizar a versão beta de hospedagem própria."
|
||||
|
||||
#: src/view/com/auth/onboarding/WelcomeDesktop.tsx:80
|
||||
#: src/view/com/auth/onboarding/WelcomeMobile.tsx:80
|
||||
@@ -483,10 +461,6 @@ msgstr "Bluesky é público."
|
||||
msgid "Bluesky will not show your profile and posts to logged-out users. Other apps may not honor this request. This does not make your account private."
|
||||
msgstr "O Bluesky não mostrará seu perfil e publicações para usuários desconectados. Outros aplicativos podem não honrar esta solicitação. Isso não torna a sua conta privada."
|
||||
|
||||
#: src/view/com/modals/ServerInput.tsx:78
|
||||
#~ msgid "Bluesky.Social"
|
||||
#~ msgstr "Bluesky.Social"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:33
|
||||
msgid "Books"
|
||||
msgstr "Livros"
|
||||
@@ -500,10 +474,6 @@ msgstr "Versão {0} {1}"
|
||||
msgid "Business"
|
||||
msgstr "Empresarial"
|
||||
|
||||
#: src/view/com/modals/ServerInput.tsx:115
|
||||
#~ msgid "Button disabled. Input custom domain to proceed."
|
||||
#~ msgstr "Botão desabilitado. Utilize um domínio personalizado para continuar."
|
||||
|
||||
#: src/view/com/profile/ProfileSubpageHeader.tsx:157
|
||||
msgid "by —"
|
||||
msgstr "por -"
|
||||
@@ -665,10 +635,6 @@ msgstr "Escolha os algoritmos que geram seus feeds customizados."
|
||||
msgid "Choose the algorithms that power your experience with custom feeds."
|
||||
msgstr "Escolha os algoritmos que fazem sentido para você com os feeds personalizados."
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:103
|
||||
#~ msgid "Choose your algorithmic feeds"
|
||||
#~ msgstr "Escolha seus feeds algoritmicos"
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:103
|
||||
msgid "Choose your main feeds"
|
||||
msgstr "Escolha seus feeds principais"
|
||||
@@ -706,11 +672,11 @@ msgstr "clique aqui"
|
||||
|
||||
#: src/components/TagMenu/index.web.tsx:138
|
||||
msgid "Click here to open tag menu for {tag}"
|
||||
msgstr ""
|
||||
msgstr "Clique aqui para abrir o menu da tag {tag}"
|
||||
|
||||
#: src/components/RichText.tsx:191
|
||||
msgid "Click here to open tag menu for #{tag}"
|
||||
msgstr ""
|
||||
msgstr "Clique aqui para abrir o menu da tag #{tag}"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:35
|
||||
msgid "Climate"
|
||||
@@ -748,7 +714,7 @@ msgstr "Fechar o painel de navegação"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:262
|
||||
msgid "Close this dialog"
|
||||
msgstr ""
|
||||
msgstr "Fechar esta janela"
|
||||
|
||||
#: src/view/shell/index.web.tsx:52
|
||||
msgid "Closes bottom navigation bar"
|
||||
@@ -789,7 +755,7 @@ msgstr "Completar e começar a usar sua conta"
|
||||
|
||||
#: src/view/com/auth/create/Step3.tsx:73
|
||||
msgid "Complete the challenge"
|
||||
msgstr ""
|
||||
msgstr "Complete o captcha"
|
||||
|
||||
#: src/view/com/composer/Composer.tsx:424
|
||||
msgid "Compose posts up to {MAX_GRAPHEME_LENGTH} characters in length"
|
||||
@@ -964,10 +930,6 @@ msgstr "Não foi possível carregar o feed"
|
||||
msgid "Could not load list"
|
||||
msgstr "Não foi possível carregar a lista"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:91
|
||||
#~ msgid "Country"
|
||||
#~ msgstr "País"
|
||||
|
||||
#: src/view/com/auth/HomeLoggedOutCTA.tsx:62
|
||||
#: src/view/com/auth/SplashScreen.tsx:71
|
||||
#: src/view/com/auth/SplashScreen.web.tsx:81
|
||||
@@ -1014,7 +976,7 @@ msgstr "Cultura"
|
||||
#: src/view/com/auth/server-input/index.tsx:95
|
||||
#: src/view/com/auth/server-input/index.tsx:96
|
||||
msgid "Custom"
|
||||
msgstr ""
|
||||
msgstr "Customizado"
|
||||
|
||||
#: src/view/com/modals/ChangeHandle.tsx:389
|
||||
msgid "Custom domain"
|
||||
@@ -1029,10 +991,6 @@ msgstr "Feeds customizados feitos pela comunidade te proporcionam novas experiê
|
||||
msgid "Customize media from external sites."
|
||||
msgstr "Configurar mídia de sites externos."
|
||||
|
||||
#: src/view/screens/Settings.tsx:687
|
||||
#~ msgid "Danger Zone"
|
||||
#~ msgstr "Zona Perigosa"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:485
|
||||
#: src/view/screens/Settings/index.tsx:511
|
||||
msgid "Dark"
|
||||
@@ -1072,10 +1030,6 @@ msgstr "Excluir Lista"
|
||||
msgid "Delete my account"
|
||||
msgstr "Excluir minha conta"
|
||||
|
||||
#: src/view/screens/Settings.tsx:706
|
||||
#~ msgid "Delete my account…"
|
||||
#~ msgstr "Excluir minha conta…"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:784
|
||||
msgid "Delete My Account…"
|
||||
msgstr "Excluir minha conta…"
|
||||
@@ -1133,13 +1087,9 @@ msgstr "Desencorajar aplicativos a mostrar minha conta para usuários deslogados
|
||||
msgid "Discover new custom feeds"
|
||||
msgstr "Descubra novos feeds"
|
||||
|
||||
#: src/view/screens/Feeds.tsx:473
|
||||
#~ msgid "Discover new feeds"
|
||||
#~ msgstr "Descubra novos feeds"
|
||||
|
||||
#: src/view/screens/Feeds.tsx:689
|
||||
msgid "Discover New Feeds"
|
||||
msgstr ""
|
||||
msgstr "Descubra Novos Feeds"
|
||||
|
||||
#: src/view/com/modals/EditProfile.tsx:192
|
||||
msgid "Display name"
|
||||
@@ -1196,12 +1146,12 @@ msgstr "Toque duas vezes para logar"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:755
|
||||
msgid "Download Bluesky account data (repository)"
|
||||
msgstr ""
|
||||
msgstr "Baixar os dados da minha conta Bluesky (repositório)"
|
||||
|
||||
#: src/view/screens/Settings/ExportCarDialog.tsx:59
|
||||
#: src/view/screens/Settings/ExportCarDialog.tsx:63
|
||||
msgid "Download CAR file"
|
||||
msgstr ""
|
||||
msgstr "Baixar arquivo CAR"
|
||||
|
||||
#: src/view/com/composer/text-input/TextInput.web.tsx:249
|
||||
msgid "Drop to add images"
|
||||
@@ -1360,7 +1310,7 @@ msgstr "Insira um nome para esta Senha de Aplicativo"
|
||||
#: src/components/dialogs/MutedWords.tsx:100
|
||||
#: src/components/dialogs/MutedWords.tsx:101
|
||||
msgid "Enter a word or tag"
|
||||
msgstr ""
|
||||
msgstr "Digite uma palavra ou tag"
|
||||
|
||||
#: src/view/com/modals/VerifyEmail.tsx:105
|
||||
msgid "Enter Confirmation Code"
|
||||
@@ -1399,17 +1349,13 @@ msgstr "Digite o novo e-mail acima"
|
||||
msgid "Enter your new email address below."
|
||||
msgstr "Digite seu novo endereço de e-mail abaixo."
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:188
|
||||
#~ msgid "Enter your phone number"
|
||||
#~ msgstr "Digite seu número de telefone"
|
||||
|
||||
#: src/view/com/auth/login/Login.tsx:99
|
||||
msgid "Enter your username and password"
|
||||
msgstr "Digite seu nome de usuário e senha"
|
||||
|
||||
#: src/view/com/auth/create/Step3.tsx:67
|
||||
msgid "Error receiving captcha response."
|
||||
msgstr ""
|
||||
msgstr "Não foi possível processar o captcha."
|
||||
|
||||
#: src/view/screens/Search/Search.tsx:110
|
||||
msgid "Error:"
|
||||
@@ -1447,12 +1393,12 @@ msgstr "Mostrar ou esconder o post a que você está respondendo"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:753
|
||||
msgid "Export my data"
|
||||
msgstr ""
|
||||
msgstr "Exportar meus dados"
|
||||
|
||||
#: src/view/screens/Settings/ExportCarDialog.tsx:44
|
||||
#: src/view/screens/Settings/index.tsx:764
|
||||
msgid "Export My Data"
|
||||
msgstr ""
|
||||
msgstr "Exportar Meus Dados"
|
||||
|
||||
#: src/view/com/modals/EmbedConsent.tsx:64
|
||||
msgid "External Media"
|
||||
@@ -1523,14 +1469,6 @@ msgstr "Comentários"
|
||||
msgid "Feeds"
|
||||
msgstr "Feeds"
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:106
|
||||
#~ msgid "Feeds are created by users and can give you entirely new experiences."
|
||||
#~ msgstr "Feeds são criados por usuários e podem te dar experiências completamente únicas."
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:106
|
||||
#~ msgid "Feeds are created by users and organizations. They offer you varied experiences and suggest content you may like using algorithms."
|
||||
#~ msgstr "Feeds são criados por usuários ou organizações. Eles oferecem experiências únicas e podem te sugerir conteúdo usando algoritmos próprios."
|
||||
|
||||
#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:57
|
||||
msgid "Feeds are created by users to curate content. Choose some feeds that you find interesting."
|
||||
msgstr "Os feeds são criados por usuários para curadoria de conteúdo. Escolha alguns feeds que você acha interessantes."
|
||||
@@ -1567,11 +1505,7 @@ msgstr "Procurando contas semelhantes..."
|
||||
|
||||
#: src/view/screens/PreferencesFollowingFeed.tsx:111
|
||||
msgid "Fine-tune the content you see on your Following feed."
|
||||
msgstr ""
|
||||
|
||||
#: src/view/screens/PreferencesHomeFeed.tsx:111
|
||||
#~ msgid "Fine-tune the content you see on your home screen."
|
||||
#~ msgstr "Ajuste o conteúdo que você vê na sua tela inicial."
|
||||
msgstr "Ajuste o conteúdo que você vê na sua tela inicial."
|
||||
|
||||
#: src/view/screens/PreferencesThreads.tsx:60
|
||||
msgid "Fine-tune the discussion threads."
|
||||
@@ -1659,7 +1593,7 @@ msgstr "Seguindo {0}"
|
||||
#: src/view/screens/PreferencesFollowingFeed.tsx:104
|
||||
#: src/view/screens/Settings/index.tsx:543
|
||||
msgid "Following Feed Preferences"
|
||||
msgstr ""
|
||||
msgstr "Configurações do feed principal"
|
||||
|
||||
#: src/view/com/profile/ProfileHeader.tsx:546
|
||||
msgid "Follows you"
|
||||
@@ -1697,7 +1631,7 @@ msgstr "Esqueci a Senha"
|
||||
#: src/screens/Hashtag.tsx:108
|
||||
#: src/screens/Hashtag.tsx:148
|
||||
msgid "From @{sanitizedAuthor}"
|
||||
msgstr ""
|
||||
msgstr "De @{sanitizedAuthor}"
|
||||
|
||||
#: src/view/com/posts/FeedItem.tsx:189
|
||||
msgctxt "from-feed"
|
||||
@@ -1751,15 +1685,15 @@ msgstr "Usuário"
|
||||
|
||||
#: src/Navigation.tsx:270
|
||||
msgid "Hashtag"
|
||||
msgstr ""
|
||||
msgstr "Hashtag"
|
||||
|
||||
#: src/components/RichText.tsx:188
|
||||
#~ msgid "Hashtag: {tag}"
|
||||
#~ msgstr ""
|
||||
#~ msgstr "Hashtag: {tag}"
|
||||
|
||||
#: src/components/RichText.tsx:190
|
||||
msgid "Hashtag: #{tag}"
|
||||
msgstr ""
|
||||
msgstr "Hashtag: #{tag}"
|
||||
|
||||
#: src/view/com/auth/create/CreateAccount.tsx:208
|
||||
msgid "Having trouble?"
|
||||
@@ -1930,10 +1864,6 @@ msgstr "Insira a nova senha"
|
||||
msgid "Input password for account deletion"
|
||||
msgstr "Insira a senha para excluir a conta"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:196
|
||||
#~ msgid "Input phone number for SMS verification"
|
||||
#~ msgstr "Insira o número de telefone para verificação via SMS"
|
||||
|
||||
#: src/view/com/auth/login/LoginForm.tsx:230
|
||||
msgid "Input the password tied to {identifier}"
|
||||
msgstr "Insira a senha da conta {identifier}"
|
||||
@@ -1942,10 +1872,6 @@ msgstr "Insira a senha da conta {identifier}"
|
||||
msgid "Input the username or email address you used at signup"
|
||||
msgstr "Insira o usuário ou e-mail que você cadastrou"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:271
|
||||
#~ msgid "Input the verification code we have texted to you"
|
||||
#~ msgstr "Insira o código de verificação que enviamos para você"
|
||||
|
||||
#: src/view/com/modals/Waitlist.tsx:90
|
||||
#~ msgid "Input your email to get on the Bluesky waitlist"
|
||||
#~ msgstr "Insira seu e-mail para entrar na lista de espera do Bluesky"
|
||||
@@ -1966,10 +1892,6 @@ msgstr "Post inválido"
|
||||
msgid "Invalid username or password"
|
||||
msgstr "Credenciais inválidas"
|
||||
|
||||
#: src/view/screens/Settings.tsx:411
|
||||
#~ msgid "Invite"
|
||||
#~ msgstr "Convidar"
|
||||
|
||||
#: src/view/com/modals/InviteCodes.tsx:93
|
||||
msgid "Invite a Friend"
|
||||
msgstr "Convide um Amigo"
|
||||
@@ -1987,10 +1909,6 @@ msgstr "Convite inválido. Verifique se você o inseriu corretamente e tente nov
|
||||
msgid "Invite codes: {0} available"
|
||||
msgstr "Convites: {0} disponíveis"
|
||||
|
||||
#: src/view/shell/Drawer.tsx:645
|
||||
#~ msgid "Invite codes: {invitesAvailable} available"
|
||||
#~ msgstr "Convites: {invitesAvailable} disponível"
|
||||
|
||||
#: src/view/com/modals/InviteCodes.tsx:169
|
||||
msgid "Invite codes: 1 available"
|
||||
msgstr "Convites: 1 disponível"
|
||||
@@ -2232,15 +2150,15 @@ msgstr "Certifique-se de onde está indo!"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:83
|
||||
msgid "Manage your muted words and tags"
|
||||
msgstr ""
|
||||
msgstr "Gerencie suas palavras/tags silenciadas"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:118
|
||||
msgid "May not be longer than 253 characters"
|
||||
msgstr ""
|
||||
msgstr "Não pode ter mais que 253 caracteres"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:109
|
||||
msgid "May only contain letters and numbers"
|
||||
msgstr ""
|
||||
msgstr "Só pode conter letras e números"
|
||||
|
||||
#: src/view/screens/Profile.tsx:182
|
||||
msgid "Media"
|
||||
@@ -2332,15 +2250,15 @@ msgstr "Respostas mais curtidas primeiro"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:122
|
||||
msgid "Must be at least 3 characters"
|
||||
msgstr ""
|
||||
msgstr "Deve ter no mínimo 3 caracteres"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:249
|
||||
msgid "Mute"
|
||||
msgstr ""
|
||||
msgstr "Silenciar"
|
||||
|
||||
#: src/components/TagMenu/index.web.tsx:105
|
||||
msgid "Mute {truncatedTag}"
|
||||
msgstr ""
|
||||
msgstr "Silenciar {truncatedTag}"
|
||||
|
||||
#: src/view/com/profile/ProfileHeader.tsx:327
|
||||
msgid "Mute Account"
|
||||
@@ -2352,19 +2270,19 @@ msgstr "Silenciar contas"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:209
|
||||
msgid "Mute all {displayTag} posts"
|
||||
msgstr ""
|
||||
msgstr "Silenciar posts com {displayTag}"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:211
|
||||
#~ msgid "Mute all {tag} posts"
|
||||
#~ msgstr ""
|
||||
#~ msgstr "Silenciar posts com {tag}"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:149
|
||||
msgid "Mute in tags only"
|
||||
msgstr ""
|
||||
msgstr "Silenciar apenas as tags"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:134
|
||||
msgid "Mute in text & tags"
|
||||
msgstr ""
|
||||
msgstr "Silenciar texto e tags"
|
||||
|
||||
#: src/view/screens/ProfileList.tsx:491
|
||||
msgid "Mute list"
|
||||
@@ -2380,11 +2298,11 @@ msgstr "Silenciar esta lista"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:127
|
||||
msgid "Mute this word in post text and tags"
|
||||
msgstr ""
|
||||
msgstr "Silenciar esta palavra no conteúdo de um post e tags"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:142
|
||||
msgid "Mute this word in tags only"
|
||||
msgstr ""
|
||||
msgstr "Silenciar esta palavra apenas nas tags de um post"
|
||||
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:251
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:257
|
||||
@@ -2394,7 +2312,7 @@ msgstr "Silenciar thread"
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:267
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:269
|
||||
msgid "Mute words & tags"
|
||||
msgstr ""
|
||||
msgstr "Silenciar palavras/tags"
|
||||
|
||||
#: src/view/com/lists/ListCard.tsx:102
|
||||
msgid "Muted"
|
||||
@@ -2415,7 +2333,7 @@ msgstr "Contas silenciadas não aparecem no seu feed ou nas suas notificações.
|
||||
|
||||
#: src/view/screens/Moderation.tsx:100
|
||||
msgid "Muted words & tags"
|
||||
msgstr ""
|
||||
msgstr "Palavras/tags silenciadas"
|
||||
|
||||
#: src/view/screens/ProfileList.tsx:277
|
||||
msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them."
|
||||
@@ -2439,7 +2357,7 @@ msgstr "Meus Feeds Salvos"
|
||||
|
||||
#: src/view/com/auth/server-input/index.tsx:118
|
||||
msgid "my-server.com"
|
||||
msgstr ""
|
||||
msgstr "meu-servidor.com.br"
|
||||
|
||||
#: src/view/com/modals/AddAppPasswords.tsx:179
|
||||
#: src/view/com/modals/CreateOrEditList.tsx:290
|
||||
@@ -2482,7 +2400,7 @@ msgstr "Nunca perca o acesso aos seus seguidores ou dados."
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:293
|
||||
msgid "Nevermind"
|
||||
msgstr ""
|
||||
msgstr "Deixa pra lá"
|
||||
|
||||
#: src/view/screens/Lists.tsx:76
|
||||
msgctxt "action"
|
||||
@@ -2587,7 +2505,7 @@ msgstr "Nenhum resultado"
|
||||
|
||||
#: src/components/Lists.tsx:192
|
||||
msgid "No results found"
|
||||
msgstr ""
|
||||
msgstr "Nenhum resultado encontrado"
|
||||
|
||||
#: src/view/screens/Feeds.tsx:495
|
||||
msgid "No results found for \"{query}\""
|
||||
@@ -2669,7 +2587,7 @@ msgstr "Apenas {0} pode responder."
|
||||
|
||||
#: src/components/Lists.tsx:82
|
||||
msgid "Oops, something went wrong!"
|
||||
msgstr ""
|
||||
msgstr "Opa, algo deu errado!"
|
||||
|
||||
#: src/components/Lists.tsx:188
|
||||
#: src/view/screens/AppPasswords.tsx:65
|
||||
@@ -2683,7 +2601,7 @@ msgstr "Abrir"
|
||||
|
||||
#: src/view/screens/Moderation.tsx:75
|
||||
msgid "Open content filtering settings"
|
||||
msgstr ""
|
||||
msgstr "Abrir configurações de filtro"
|
||||
|
||||
#: src/view/com/composer/Composer.tsx:477
|
||||
#: src/view/com/composer/Composer.tsx:478
|
||||
@@ -2696,7 +2614,7 @@ msgstr "Abrir links no navegador interno"
|
||||
|
||||
#: src/view/screens/Moderation.tsx:92
|
||||
msgid "Open muted words settings"
|
||||
msgstr ""
|
||||
msgstr "Abrir configurações das palavras silenciadas"
|
||||
|
||||
#: src/view/com/home/HomeHeaderLayoutMobile.tsx:50
|
||||
msgid "Open navigation"
|
||||
@@ -2704,7 +2622,7 @@ msgstr "Abrir navegação"
|
||||
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:175
|
||||
msgid "Open post options menu"
|
||||
msgstr ""
|
||||
msgstr "Abrir opções do post"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:804
|
||||
msgid "Open storybook page"
|
||||
@@ -2754,10 +2672,6 @@ msgstr "Abre lista de seguidores"
|
||||
msgid "Opens following list"
|
||||
msgstr "Abre lista de seguidos"
|
||||
|
||||
#: src/view/screens/Settings.tsx:412
|
||||
#~ msgid "Opens invite code list"
|
||||
#~ msgstr "Abre lista de convites"
|
||||
|
||||
#: src/view/com/modals/InviteCodes.tsx:172
|
||||
msgid "Opens list of invite codes"
|
||||
msgstr "Abre a lista de códigos de convite"
|
||||
@@ -2819,10 +2733,6 @@ msgstr "Ou combine estas opções:"
|
||||
msgid "Other account"
|
||||
msgstr "Outra conta"
|
||||
|
||||
#: src/view/com/modals/ServerInput.tsx:88
|
||||
#~ msgid "Other service"
|
||||
#~ msgstr "Outro serviço"
|
||||
|
||||
#: src/view/com/composer/select-language/SelectLangBtn.tsx:91
|
||||
msgid "Other..."
|
||||
msgstr "Outro..."
|
||||
@@ -2872,10 +2782,6 @@ msgstr "A permissão de galeria foi recusada. Por favor, habilite-a nas configur
|
||||
msgid "Pets"
|
||||
msgstr "Pets"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:183
|
||||
#~ msgid "Phone number"
|
||||
#~ msgstr "Número de telefone"
|
||||
|
||||
#: src/view/com/modals/SelfLabel.tsx:121
|
||||
msgid "Pictures meant for adults."
|
||||
msgstr "Imagens destinadas a adultos."
|
||||
@@ -2912,7 +2818,7 @@ msgstr "Por favor, escolha sua senha."
|
||||
|
||||
#: src/view/com/auth/create/state.ts:131
|
||||
msgid "Please complete the verification captcha."
|
||||
msgstr ""
|
||||
msgstr "Por favor, complete o captcha de verificação."
|
||||
|
||||
#: src/view/com/modals/ChangeEmail.tsx:67
|
||||
msgid "Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed."
|
||||
@@ -2922,17 +2828,13 @@ msgstr "Por favor, confirme seu e-mail antes de alterá-lo. Este é um requisito
|
||||
msgid "Please enter a name for your app password. All spaces is not allowed."
|
||||
msgstr "Por favor, insira um nome para a sua Senha de Aplicativo."
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:206
|
||||
#~ msgid "Please enter a phone number that can receive SMS text messages."
|
||||
#~ msgstr "Por favor, insira um número de telefone que possa receber mensagens SMS."
|
||||
|
||||
#: src/view/com/modals/AddAppPasswords.tsx:145
|
||||
msgid "Please enter a unique name for this App Password or use our randomly generated one."
|
||||
msgstr "Por favor, insira um nome único para esta Senha de Aplicativo ou use nosso nome gerado automaticamente."
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:68
|
||||
msgid "Please enter a valid word, tag, or phrase to mute"
|
||||
msgstr ""
|
||||
msgstr "Por favor, insira uma palavra, tag ou frase para silenciar"
|
||||
|
||||
#: src/view/com/auth/create/state.ts:170
|
||||
#~ msgid "Please enter the code you received by SMS."
|
||||
@@ -2955,11 +2857,6 @@ msgstr "Por favor, digite sua senha também:"
|
||||
msgid "Please tell us why you think this content warning was incorrectly applied!"
|
||||
msgstr "Por favor, diga-nos por que você acha que este aviso de conteúdo foi aplicado incorretamente!"
|
||||
|
||||
#: src/view/com/modals/AppealLabel.tsx:72
|
||||
#: src/view/com/modals/AppealLabel.tsx:75
|
||||
#~ msgid "Please tell us why you think this decision was incorrect."
|
||||
#~ msgstr "Por favor, conte-nos por que achou que esta decisão está incorreta."
|
||||
|
||||
#: src/view/com/modals/VerifyEmail.tsx:101
|
||||
msgid "Please Verify Your Email"
|
||||
msgstr "Por favor, verifique seu e-mail"
|
||||
@@ -3019,7 +2916,7 @@ msgstr "Post não encontrado"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:253
|
||||
msgid "posts"
|
||||
msgstr ""
|
||||
msgstr "posts"
|
||||
|
||||
#: src/view/screens/Profile.tsx:180
|
||||
msgid "Posts"
|
||||
@@ -3027,7 +2924,7 @@ msgstr "Posts"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:90
|
||||
msgid "Posts can be muted based on their text, their tags, or both."
|
||||
msgstr ""
|
||||
msgstr "Posts podem ser silenciados baseados no seu conteúdo, tags ou ambos."
|
||||
|
||||
#: src/view/com/posts/FeedErrorMessage.tsx:64
|
||||
msgid "Posts hidden"
|
||||
@@ -3171,7 +3068,7 @@ msgstr "Remover visualização da imagem"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:343
|
||||
msgid "Remove mute word from your list"
|
||||
msgstr ""
|
||||
msgstr "Remover palavra silenciada da lista"
|
||||
|
||||
#: src/view/com/modals/Repost.tsx:47
|
||||
msgid "Remove repost"
|
||||
@@ -3286,10 +3183,6 @@ msgstr "Reposts"
|
||||
msgid "Request Change"
|
||||
msgstr "Solicitar Alteração"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:219
|
||||
#~ msgid "Request code"
|
||||
#~ msgstr "Solicitar código"
|
||||
|
||||
#: src/view/com/modals/ChangePassword.tsx:239
|
||||
#: src/view/com/modals/ChangePassword.tsx:241
|
||||
msgid "Request Code"
|
||||
@@ -3368,10 +3261,6 @@ msgstr "Tente novamente"
|
||||
msgid "Return to previous page"
|
||||
msgstr "Voltar para página anterior"
|
||||
|
||||
#: src/view/shell/desktop/RightNav.tsx:55
|
||||
#~ msgid "SANDBOX. Posts and accounts are not permanent."
|
||||
#~ msgstr "SANDBOX. Posts e contas não são permanentes."
|
||||
|
||||
#: src/view/com/lightbox/Lightbox.tsx:132
|
||||
#: src/view/com/modals/CreateOrEditList.tsx:345
|
||||
msgctxt "action"
|
||||
@@ -3447,19 +3336,19 @@ msgstr "Pesquisar por \"{query}\""
|
||||
|
||||
#: src/components/TagMenu/index.tsx:145
|
||||
msgid "Search for all posts by @{authorHandle} with tag {displayTag}"
|
||||
msgstr ""
|
||||
msgstr "Pesquisar por posts de @{authorHandle} com a tag {displayTag}"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:145
|
||||
#~ msgid "Search for all posts by @{authorHandle} with tag {tag}"
|
||||
#~ msgstr ""
|
||||
#~ msgstr "Pesquisar por posts de @{authorHandle} com a tag {tag}"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:94
|
||||
msgid "Search for all posts with tag {displayTag}"
|
||||
msgstr ""
|
||||
msgstr "Pesquisar por posts com a tag {displayTag}"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:90
|
||||
#~ msgid "Search for all posts with tag {tag}"
|
||||
#~ msgstr ""
|
||||
#~ msgstr "Pesquisar por posts com a tag {tag}"
|
||||
|
||||
#: src/view/com/auth/LoggedOut.tsx:104
|
||||
#: src/view/com/auth/LoggedOut.tsx:105
|
||||
@@ -3473,27 +3362,27 @@ msgstr "Passo de Segurança Necessário"
|
||||
|
||||
#: src/components/TagMenu/index.web.tsx:66
|
||||
msgid "See {truncatedTag} posts"
|
||||
msgstr ""
|
||||
msgstr "Ver posts com {truncatedTag}"
|
||||
|
||||
#: src/components/TagMenu/index.web.tsx:83
|
||||
msgid "See {truncatedTag} posts by user"
|
||||
msgstr ""
|
||||
msgstr "Ver posts com {truncatedTag} deste usuário"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:128
|
||||
msgid "See <0>{displayTag}</0> posts"
|
||||
msgstr ""
|
||||
msgstr "Ver posts com <0>{displayTag}</0>"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:187
|
||||
msgid "See <0>{displayTag}</0> posts by this user"
|
||||
msgstr ""
|
||||
msgstr "Ver posts com <0>{displayTag}</0> deste usuário"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:128
|
||||
#~ msgid "See <0>{tag}</0> posts"
|
||||
#~ msgstr ""
|
||||
#~ msgstr "Ver posts com <0>{tag}</0>"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:189
|
||||
#~ msgid "See <0>{tag}</0> posts by this user"
|
||||
#~ msgstr ""
|
||||
#~ msgstr "Ver posts com <0>{tag}</0> deste usuário"
|
||||
|
||||
#: src/view/screens/SavedFeeds.tsx:163
|
||||
msgid "See this guide"
|
||||
@@ -3507,10 +3396,6 @@ msgstr "Veja o que vem por aí"
|
||||
msgid "Select {item}"
|
||||
msgstr "Selecionar {item}"
|
||||
|
||||
#: src/view/com/modals/ServerInput.tsx:75
|
||||
#~ msgid "Select Bluesky Social"
|
||||
#~ msgstr "Selecionar Bluesky Social"
|
||||
|
||||
#: src/view/com/auth/login/Login.tsx:117
|
||||
msgid "Select from an existing account"
|
||||
msgstr "Selecionar de uma conta existente"
|
||||
@@ -3530,11 +3415,7 @@ msgstr "Selecione algumas contas para seguir"
|
||||
|
||||
#: src/view/com/auth/server-input/index.tsx:82
|
||||
msgid "Select the service that hosts your data."
|
||||
msgstr ""
|
||||
|
||||
#: src/screens/Onboarding/StepModeration/index.tsx:49
|
||||
#~ msgid "Select the types of content that you want to see (or not see), and we'll handle the rest."
|
||||
#~ msgstr "Selecione os tipos de conteúdo que você quer (ou não) ver, e cuidaremos do resto."
|
||||
msgstr "Selecione o serviço que hospeda seus dados."
|
||||
|
||||
#: src/screens/Onboarding/StepTopicalFeeds.tsx:96
|
||||
msgid "Select topical feeds to follow from the list below"
|
||||
@@ -3556,10 +3437,6 @@ msgstr "Selecione o idioma do seu aplicativo"
|
||||
msgid "Select your interests from the options below"
|
||||
msgstr "Selecione seus interesses"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:155
|
||||
#~ msgid "Select your phone's country"
|
||||
#~ msgstr "Selecione o país do número de telefone"
|
||||
|
||||
#: src/view/screens/LanguageSettings.tsx:190
|
||||
msgid "Select your preferred language for translations in your feed."
|
||||
msgstr "Selecione seu idioma preferido para as traduções no seu feed."
|
||||
@@ -3601,7 +3478,7 @@ msgstr "Envia o e-mail com o código de confirmação para excluir a conta"
|
||||
|
||||
#: src/view/com/auth/server-input/index.tsx:110
|
||||
msgid "Server address"
|
||||
msgstr ""
|
||||
msgstr "URL do servidor"
|
||||
|
||||
#: src/view/com/modals/ContentFilteringSettings.tsx:311
|
||||
msgid "Set {value} for {labelGroup} content moderation policy"
|
||||
@@ -3657,13 +3534,9 @@ msgstr "Defina esta configuração como \"Não\" para ocultar todos os reposts d
|
||||
msgid "Set this setting to \"Yes\" to show replies in a threaded view. This is an experimental feature."
|
||||
msgstr "Defina esta configuração como \"Sim\" para mostrar respostas em uma visualização de thread. Este é um recurso experimental."
|
||||
|
||||
#: src/view/screens/PreferencesHomeFeed.tsx:261
|
||||
#~ msgid "Set this setting to \"Yes\" to show samples of your saved feeds in your following feed. This is an experimental feature."
|
||||
#~ msgstr "Defina esta configuração como \"Sim\" para mostrar amostras de seus feeds salvos na sua página inicial. Este é um recurso experimental."
|
||||
|
||||
#: src/view/screens/PreferencesFollowingFeed.tsx:261
|
||||
msgid "Set this setting to \"Yes\" to show samples of your saved feeds in your Following feed. This is an experimental feature."
|
||||
msgstr ""
|
||||
msgstr "Defina esta configuração como \"Sim\" para exibir amostras de seus feeds salvos no seu feed inicial. Este é um recurso experimental."
|
||||
|
||||
#: src/screens/Onboarding/Layout.tsx:50
|
||||
msgid "Set up your account"
|
||||
@@ -3893,10 +3766,6 @@ msgstr "Pular"
|
||||
msgid "Skip this flow"
|
||||
msgstr "Pular"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:82
|
||||
#~ msgid "SMS verification"
|
||||
#~ msgstr "Verificação por SMS"
|
||||
|
||||
#: src/screens/Onboarding/index.tsx:40
|
||||
msgid "Software Dev"
|
||||
msgstr "Desenvolvimento de software"
|
||||
@@ -3907,7 +3776,7 @@ msgstr "Desenvolvimento de software"
|
||||
|
||||
#: src/components/Lists.tsx:203
|
||||
msgid "Something went wrong!"
|
||||
msgstr ""
|
||||
msgstr "Algo deu errado!"
|
||||
|
||||
#: src/view/com/modals/Waitlist.tsx:51
|
||||
#~ msgid "Something went wrong. Check your email and try again."
|
||||
@@ -3933,10 +3802,6 @@ msgstr "Esportes"
|
||||
msgid "Square"
|
||||
msgstr "Quadrado"
|
||||
|
||||
#: src/view/com/modals/ServerInput.tsx:62
|
||||
#~ msgid "Staging"
|
||||
#~ msgstr "Staging"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:871
|
||||
msgid "Status page"
|
||||
msgstr "Página de status"
|
||||
@@ -3989,10 +3854,6 @@ msgstr "Sugestivo"
|
||||
msgid "Support"
|
||||
msgstr "Suporte"
|
||||
|
||||
#: src/view/com/modals/ProfilePreview.tsx:110
|
||||
#~ msgid "Swipe up to see more"
|
||||
#~ msgstr "Deslize para cima para ver mais"
|
||||
|
||||
#: src/view/com/modals/SwitchAccount.tsx:117
|
||||
msgid "Switch Account"
|
||||
msgstr "Alterar Conta"
|
||||
@@ -4017,15 +3878,15 @@ msgstr "Log do sistema"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:337
|
||||
msgid "tag"
|
||||
msgstr ""
|
||||
msgstr "tag"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:78
|
||||
msgid "Tag menu: {displayTag}"
|
||||
msgstr ""
|
||||
msgstr "Menu da tag: {displayTag}"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:74
|
||||
#~ msgid "Tag menu: {tag}"
|
||||
#~ msgstr ""
|
||||
#~ msgstr "Menu da tag: {tag}"
|
||||
|
||||
#: src/view/com/modals/crop-image/CropImage.web.tsx:112
|
||||
msgid "Tall"
|
||||
@@ -4052,7 +3913,7 @@ msgstr "Termos de Serviço"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:337
|
||||
msgid "text"
|
||||
msgstr ""
|
||||
msgstr "texto"
|
||||
|
||||
#: src/view/com/modals/AppealLabel.tsx:70
|
||||
#: src/view/com/modals/report/InputIssueDetails.tsx:51
|
||||
@@ -4061,7 +3922,7 @@ msgstr "Campo de entrada de texto"
|
||||
|
||||
#: src/view/com/auth/create/CreateAccount.tsx:94
|
||||
msgid "That handle is already taken."
|
||||
msgstr ""
|
||||
msgstr "Este identificador de usuário já está sendo usado."
|
||||
|
||||
#: src/view/com/profile/ProfileHeader.tsx:263
|
||||
msgid "The account will be able to interact with you after unblocking."
|
||||
@@ -4179,10 +4040,6 @@ msgstr "Houve um problema inesperado no aplicativo. Por favor, deixe-nos saber s
|
||||
msgid "There's been a rush of new users to Bluesky! We'll activate your account as soon as we can."
|
||||
msgstr "Muitos usuários estão tentando acessar o Bluesky! Ativaremos sua conta assim que possível."
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:55
|
||||
#~ msgid "There's something wrong with this number. Please choose your country and enter your full phone number!"
|
||||
#~ msgstr "Houve um problema com este número. Por favor, escolha um país e digite seu número de telefone completo!"
|
||||
|
||||
#: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:138
|
||||
msgid "These are popular accounts you might like:"
|
||||
msgstr "Estas são contas populares que talvez você goste:"
|
||||
@@ -4209,7 +4066,7 @@ msgstr "Este conteúdo não é visível sem uma conta do Bluesky."
|
||||
|
||||
#: src/view/screens/Settings/ExportCarDialog.tsx:75
|
||||
msgid "This feature is in beta. You can read more about repository exports in <0>this blogpost.</0>"
|
||||
msgstr ""
|
||||
msgstr "Esta funcionalidade está em beta. Você pode ler mais sobre exportação de repositórios <0>neste post</0> do nosso blog."
|
||||
|
||||
#: src/view/com/posts/FeedErrorMessage.tsx:114
|
||||
msgid "This feed is currently receiving high traffic and is temporarily unavailable. Please try again later."
|
||||
@@ -4261,17 +4118,13 @@ msgstr "Este usuário está incluído na lista <0/>, que você bloqueou."
|
||||
msgid "This user is included in the <0/> list which you have muted."
|
||||
msgstr "Este usuário está incluído na lista <0/>, que você silenciou."
|
||||
|
||||
#: src/view/com/modals/ModerationDetails.tsx:74
|
||||
#~ msgid "This user is included the <0/> list which you have muted."
|
||||
#~ msgstr "Este usuário está incluído na lista <0/>, que você silenciou."
|
||||
|
||||
#: src/view/com/modals/SelfLabel.tsx:137
|
||||
msgid "This warning is only available for posts with media attached."
|
||||
msgstr "Este aviso só está disponível para publicações com mídia anexada."
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:285
|
||||
msgid "This will delete {0} from your muted words. You can always add it back later."
|
||||
msgstr ""
|
||||
msgstr "Isso removerá {0} das suas palavras silenciadas. Você pode adicioná-la novamente depois."
|
||||
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:282
|
||||
msgid "This will hide this post from your feeds."
|
||||
@@ -4292,7 +4145,7 @@ msgstr "Preferências das Threads"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:113
|
||||
msgid "Toggle between muted word options."
|
||||
msgstr ""
|
||||
msgstr "Alternar entre opções de uma palavra silenciada"
|
||||
|
||||
#: src/view/com/util/forms/DropdownButton.tsx:246
|
||||
msgid "Toggle dropdown"
|
||||
@@ -4376,7 +4229,7 @@ msgstr "Dessilenciar"
|
||||
|
||||
#: src/components/TagMenu/index.web.tsx:104
|
||||
msgid "Unmute {truncatedTag}"
|
||||
msgstr ""
|
||||
msgstr "Dessilenciar {truncatedTag}"
|
||||
|
||||
#: src/view/com/profile/ProfileHeader.tsx:326
|
||||
msgid "Unmute Account"
|
||||
@@ -4384,11 +4237,11 @@ msgstr "Dessilenciar conta"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:208
|
||||
msgid "Unmute all {displayTag} posts"
|
||||
msgstr ""
|
||||
msgstr "Dessilenciar posts com {displayTag}"
|
||||
|
||||
#: src/components/TagMenu/index.tsx:210
|
||||
#~ msgid "Unmute all {tag} posts"
|
||||
#~ msgstr ""
|
||||
#~ msgstr "Dessilenciar posts com {tag}"
|
||||
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:251
|
||||
#: src/view/com/util/forms/PostDropdownBtn.tsx:256
|
||||
@@ -4446,10 +4299,6 @@ msgstr "Usar o meu navegador padrão"
|
||||
msgid "Use this to sign into the other app along with your handle."
|
||||
msgstr "Use esta senha para entrar no outro aplicativo juntamente com seu identificador."
|
||||
|
||||
#: src/view/com/modals/ServerInput.tsx:105
|
||||
#~ msgid "Use your domain as your Bluesky client service provider"
|
||||
#~ msgstr "Use seu domínio como o provedor de serviço do Bluesky"
|
||||
|
||||
#: src/view/com/modals/InviteCodes.tsx:200
|
||||
msgid "Used by:"
|
||||
msgstr "Usado por:"
|
||||
@@ -4514,10 +4363,6 @@ msgstr "usuários seguidos por <0/>"
|
||||
msgid "Users in \"{0}\""
|
||||
msgstr "Usuários em \"{0}\""
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:243
|
||||
#~ msgid "Verification code"
|
||||
#~ msgstr "Código de verificação"
|
||||
|
||||
#: src/view/screens/Settings/index.tsx:910
|
||||
msgid "Verify email"
|
||||
msgstr "Verificar e-mail"
|
||||
@@ -4578,7 +4423,7 @@ msgstr "Também recomendamos o \"For You\", do Skygaze:"
|
||||
|
||||
#: src/screens/Hashtag.tsx:132
|
||||
msgid "We couldn't find any results for that hashtag."
|
||||
msgstr ""
|
||||
msgstr "Não encontramos nenhum post com esta hashtag."
|
||||
|
||||
#: src/screens/Deactivated.tsx:133
|
||||
msgid "We estimate {estimatedTime} until your account is ready."
|
||||
@@ -4598,7 +4443,7 @@ msgstr "Não temos mais posts de quem você segue. Aqui estão os mais novos de
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:204
|
||||
msgid "We recommend avoiding common words that appear in many posts, since it can result in no posts being shown."
|
||||
msgstr ""
|
||||
msgstr "Não recomendamos utilizar palavras comuns que aparecem em muitos posts, já que isso pode resultar em filtrar todos eles."
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:124
|
||||
msgid "We recommend our \"Discover\" feed:"
|
||||
@@ -4630,7 +4475,7 @@ msgstr "Tivemos um problema ao exibir esta lista. Se continuar acontecendo, cont
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:230
|
||||
msgid "We're sorry, but we weren't able to load your muted words at this time. Please try again."
|
||||
msgstr ""
|
||||
msgstr "Não foi possível carregar sua lista de palavras silenciadas. Por favor, tente novamente."
|
||||
|
||||
#: src/view/screens/Search/Search.tsx:254
|
||||
msgid "We're sorry, but your search could not be completed. Please try again in a few minutes."
|
||||
@@ -4688,10 +4533,6 @@ msgstr "Escreva sua resposta"
|
||||
msgid "Writers"
|
||||
msgstr "Escritores"
|
||||
|
||||
#: src/view/com/auth/create/Step2.tsx:263
|
||||
#~ msgid "XXXXXX"
|
||||
#~ msgstr "XXXXXX"
|
||||
|
||||
#: src/view/com/composer/select-language/SuggestedLanguage.tsx:77
|
||||
#: src/view/screens/PreferencesFollowingFeed.tsx:129
|
||||
#: src/view/screens/PreferencesFollowingFeed.tsx:201
|
||||
@@ -4702,10 +4543,6 @@ msgstr "Escritores"
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
#: src/screens/Onboarding/StepModeration/index.tsx:46
|
||||
#~ msgid "You are in control"
|
||||
#~ msgstr "Você está no controle"
|
||||
|
||||
#: src/screens/Deactivated.tsx:130
|
||||
msgid "You are in line."
|
||||
msgstr "Você está na fila."
|
||||
@@ -4715,10 +4552,6 @@ msgstr "Você está na fila."
|
||||
msgid "You can also discover new Custom Feeds to follow."
|
||||
msgstr "Você também pode descobrir novos feeds para seguir."
|
||||
|
||||
#: src/screens/Onboarding/StepAlgoFeeds/index.tsx:123
|
||||
#~ msgid "You can also try our \"Discover\" algorithm:"
|
||||
#~ msgstr "Você também pode tentar nosso algoritmo \"Discover\":"
|
||||
|
||||
#: src/screens/Onboarding/StepFollowingFeed.tsx:142
|
||||
msgid "You can change these settings later."
|
||||
msgstr "Você pode mudar estas configurações depois."
|
||||
@@ -4786,7 +4619,7 @@ msgstr "Você ainda não silenciou nenhuma conta. Para silenciar uma conta, aces
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:250
|
||||
msgid "You haven't muted any words or tags yet"
|
||||
msgstr ""
|
||||
msgstr "Você não silenciou nenhuma palavra ou tag ainda"
|
||||
|
||||
#: src/view/com/modals/ContentFilteringSettings.tsx:175
|
||||
msgid "You must be 18 or older to enable adult content."
|
||||
@@ -4836,7 +4669,7 @@ msgstr "Sua conta foi excluída"
|
||||
|
||||
#: src/view/screens/Settings/ExportCarDialog.tsx:47
|
||||
msgid "Your account repository, containing all public data records, can be downloaded as a \"CAR\" file. This file does not include media embeds, such as images, or your private data, which must be fetched separately."
|
||||
msgstr ""
|
||||
msgstr "O repositório da sua conta, contendo todos os seus dados públicos, pode ser baixado como um arquivo \"CAR\". Este arquivo não inclui imagens ou dados privados, estes devem ser exportados separadamente."
|
||||
|
||||
#: src/view/com/auth/create/Step1.tsx:215
|
||||
msgid "Your birth date"
|
||||
@@ -4888,7 +4721,7 @@ msgstr "Seu usuário completo será <0>@{0}</0>"
|
||||
|
||||
#: src/components/dialogs/MutedWords.tsx:221
|
||||
msgid "Your muted words"
|
||||
msgstr ""
|
||||
msgstr "Suas palavras silenciadas"
|
||||
|
||||
#: src/view/com/modals/ChangePassword.tsx:155
|
||||
msgid "Your password has been changed successfully!"
|
||||
|
||||
+563
-853
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,9 @@ export const router = new Router({
|
||||
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
|
||||
ProfileFeed: '/profile/:name/feed/:rkey',
|
||||
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
|
||||
ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by',
|
||||
Debug: '/sys/debug',
|
||||
DebugMod: '/sys/debug-mod',
|
||||
Log: '/sys/log',
|
||||
AppPasswords: '/settings/app-passwords',
|
||||
PreferencesFollowingFeed: '/settings/following-feed',
|
||||
|
||||
@@ -128,8 +128,8 @@ export default function HashtagScreen({
|
||||
isError={isError}
|
||||
isEmpty={posts.length < 1}
|
||||
onRetry={refetch}
|
||||
notFoundType="results"
|
||||
empty={_(msg`We couldn't find any results for that hashtag.`)}
|
||||
emptyTitle="results"
|
||||
emptyMessage={_(msg`We couldn't find any results for that hashtag.`)}
|
||||
/>
|
||||
{!isLoading && posts.length > 0 && (
|
||||
<List<PostView>
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {ComAtprotoLabelDefs} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {LABELS} from '@atproto/api'
|
||||
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
||||
|
||||
import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
useProfileQuery,
|
||||
useProfileUpdateMutation,
|
||||
} from '#/state/queries/profile'
|
||||
import {ScrollView} from '#/view/com/util/Views'
|
||||
|
||||
import {
|
||||
UsePreferencesQueryResponse,
|
||||
useMyLabelersQuery,
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetAdultContentMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {logger} from '#/logger'
|
||||
import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {InlineLink, Link} from '#/components/Link'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as LabelingService from '#/components/LabelingServiceCard'
|
||||
import {GlobalModerationLabelPref} from '#/components/moderation/GlobalModerationLabelPref'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
function ErrorState({error}: {error: string}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.p_xl]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_normal,
|
||||
a.pb_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
Hmmmm, it seems we're having trouble loading this data. See below for
|
||||
more details. If this issue persists, please contact us.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.rounded_md,
|
||||
a.mb_2xl,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.text_md, a.leading_normal]}>{error}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModerationScreen(
|
||||
_props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
isLoading: isPreferencesLoading,
|
||||
error: preferencesError,
|
||||
data: preferences,
|
||||
} = usePreferencesQuery()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {height} = useSafeAreaFrame()
|
||||
|
||||
const isLoading = isPreferencesLoading
|
||||
const error = preferencesError
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
testID="moderationScreen"
|
||||
style={[
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
{minHeight: height},
|
||||
...(gtMobile ? [a.border_l, a.border_r] : []),
|
||||
]}>
|
||||
<ViewHeader title={_(msg`Moderation`)} showOnDesktop />
|
||||
|
||||
{isLoading ? (
|
||||
<View style={[a.w_full, a.align_center, a.pt_2xl]}>
|
||||
<Loader size="xl" fill={t.atoms.text.color} />
|
||||
</View>
|
||||
) : error || !preferences ? (
|
||||
<ErrorState
|
||||
error={
|
||||
preferencesError?.toString() ||
|
||||
_(msg`Something went wrong, please try again.`)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ModerationScreenInner preferences={preferences} />
|
||||
)}
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function SubItem({
|
||||
title,
|
||||
icon: Icon,
|
||||
style,
|
||||
}: ViewStyleProp & {
|
||||
title: string
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.p_lg,
|
||||
a.gap_sm,
|
||||
style,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||
<Icon size="md" style={[t.atoms.text_contrast_medium]} />
|
||||
<Text style={[a.text_sm, a.font_bold]}>{title}</Text>
|
||||
</View>
|
||||
<ChevronRight
|
||||
size="sm"
|
||||
style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModerationScreenInner({
|
||||
preferences,
|
||||
}: {
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {screen} = useAnalytics()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
const birthdateDialogControl = Dialog.useDialogControl()
|
||||
const {
|
||||
isLoading: isLabelersLoading,
|
||||
data: labelers,
|
||||
error: labelersError,
|
||||
} = useMyLabelersQuery()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
screen('Moderation')
|
||||
setMinimalShellMode(false)
|
||||
}, [screen, setMinimalShellMode]),
|
||||
)
|
||||
|
||||
const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
|
||||
usePreferencesSetAdultContentMutation()
|
||||
const adultContentEnabled = !!(
|
||||
(optimisticAdultContent && optimisticAdultContent.enabled) ||
|
||||
(!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
|
||||
)
|
||||
const ageNotSet = !preferences.userAge
|
||||
const isUnderage = (preferences.userAge || 0) < 18
|
||||
|
||||
const onToggleAdultContentEnabled = React.useCallback(
|
||||
async (selected: boolean) => {
|
||||
try {
|
||||
await setAdultContentPref({
|
||||
enabled: selected,
|
||||
})
|
||||
} catch (e: any) {
|
||||
logger.error(`Failed to set adult content pref`, {
|
||||
message: e.message,
|
||||
})
|
||||
}
|
||||
},
|
||||
[setAdultContentPref],
|
||||
)
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
a.border_0,
|
||||
a.pt_2xl,
|
||||
a.px_lg,
|
||||
gtMobile && a.px_2xl,
|
||||
]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
|
||||
<Trans>Moderation tools</Trans>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Button
|
||||
testID="mutedWordsBtn"
|
||||
label={_(msg`Open muted words and tags settings`)}
|
||||
onPress={() => mutedWordsDialogControl.open()}>
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Muted words & tags`)}
|
||||
icon={Filter}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<Divider />
|
||||
<Link testID="moderationlistsBtn" to="/moderation/modlists">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Moderation lists`)}
|
||||
icon={Group}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
<Divider />
|
||||
<Link testID="mutedAccountsBtn" to="/moderation/muted-accounts">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Muted accounts`)}
|
||||
icon={Person}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
<Divider />
|
||||
<Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts">
|
||||
{state => (
|
||||
<SubItem
|
||||
title={_(msg`Blocked accounts`)}
|
||||
icon={CircleBanSign}
|
||||
style={[
|
||||
(state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.pt_2xl,
|
||||
a.pb_md,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Content filters</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.gap_md]}>
|
||||
{ageNotSet && (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Confirm your birthdate`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onPress={() => {
|
||||
birthdateDialogControl.open()
|
||||
}}
|
||||
style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}>
|
||||
<ButtonText>
|
||||
<Trans>Confirm your age:</Trans>
|
||||
</ButtonText>
|
||||
<ButtonText>
|
||||
<Trans>Set birthdate</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<BirthDateSettingsDialog control={birthdateDialogControl} />
|
||||
</>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
{!ageNotSet && !isUnderage && (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.py_lg,
|
||||
a.px_lg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
]}>
|
||||
<Text style={[a.font_semibold, t.atoms.text_contrast_high]}>
|
||||
<Trans>Enable adult content</Trans>
|
||||
</Text>
|
||||
<Toggle.Item
|
||||
label={_(msg`Toggle to enable or disable adult content`)}
|
||||
name="adultContent"
|
||||
value={adultContentEnabled}
|
||||
onChange={onToggleAdultContentEnabled}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{adultContentEnabled ? (
|
||||
<Trans>Enabled</Trans>
|
||||
) : (
|
||||
<Trans>Disabled</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Toggle.Switch />
|
||||
</View>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{!isUnderage && adultContentEnabled && (
|
||||
<>
|
||||
<GlobalModerationLabelPref labelValueDefinition={LABELS.porn} />
|
||||
<Divider />
|
||||
<GlobalModerationLabelPref labelValueDefinition={LABELS.sexual} />
|
||||
<Divider />
|
||||
<GlobalModerationLabelPref
|
||||
labelValueDefinition={LABELS['graphic-media']}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
<GlobalModerationLabelPref labelValueDefinition={LABELS.nudity} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.pt_2xl,
|
||||
a.pb_md,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Advanced</Trans>
|
||||
</Text>
|
||||
|
||||
{isLabelersLoading ? (
|
||||
<View style={[a.w_full, a.align_center, a.p_lg]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : labelersError || !labelers ? (
|
||||
<View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||
<Text>
|
||||
<Trans>
|
||||
We were unable to load your configured labelers at this time.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||
{labelers.map((labeler, i) => {
|
||||
return (
|
||||
<React.Fragment key={labeler.creator.did}>
|
||||
{i !== 0 && <Divider />}
|
||||
<LabelingService.Link labeler={labeler}>
|
||||
{state => (
|
||||
<LabelingService.Outer
|
||||
style={[
|
||||
i === 0 && {
|
||||
borderTopLeftRadius: a.rounded_sm.borderRadius,
|
||||
borderTopRightRadius: a.rounded_sm.borderRadius,
|
||||
},
|
||||
i === labelers.length - 1 && {
|
||||
borderBottomLeftRadius: a.rounded_sm.borderRadius,
|
||||
borderBottomRightRadius: a.rounded_sm.borderRadius,
|
||||
},
|
||||
(state.hovered || state.pressed) && [
|
||||
t.atoms.bg_contrast_50,
|
||||
],
|
||||
]}>
|
||||
<LabelingService.Avatar avatar={labeler.creator.avatar} />
|
||||
<LabelingService.Content>
|
||||
<LabelingService.Title
|
||||
value={getLabelingServiceTitle({
|
||||
displayName: labeler.creator.displayName,
|
||||
handle: labeler.creator.handle,
|
||||
})}
|
||||
/>
|
||||
<LabelingService.Description
|
||||
value={labeler.creator.description}
|
||||
handle={labeler.creator.handle}
|
||||
/>
|
||||
</LabelingService.Content>
|
||||
</LabelingService.Outer>
|
||||
)}
|
||||
</LabelingService.Link>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.pt_2xl,
|
||||
a.pb_md,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Logged-out visibility</Trans>
|
||||
</Text>
|
||||
|
||||
<PwiOptOut />
|
||||
|
||||
<View style={{height: 200}} />
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
function PwiOptOut() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
const updateProfile = useProfileUpdateMutation()
|
||||
|
||||
const isOptedOut =
|
||||
profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
|
||||
const canToggle = profile && !updateProfile.isPending
|
||||
|
||||
const onToggleOptOut = React.useCallback(() => {
|
||||
if (!profile) {
|
||||
return
|
||||
}
|
||||
let wasAdded = false
|
||||
updateProfile.mutate({
|
||||
profile,
|
||||
updates: existing => {
|
||||
// create labels attr if needed
|
||||
existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
|
||||
? existing.labels
|
||||
: {
|
||||
$type: 'com.atproto.label.defs#selfLabels',
|
||||
values: [],
|
||||
}
|
||||
|
||||
// toggle the label
|
||||
const hasLabel = existing.labels.values.some(
|
||||
l => l.val === '!no-unauthenticated',
|
||||
)
|
||||
if (hasLabel) {
|
||||
wasAdded = false
|
||||
existing.labels.values = existing.labels.values.filter(
|
||||
l => l.val !== '!no-unauthenticated',
|
||||
)
|
||||
} else {
|
||||
wasAdded = true
|
||||
existing.labels.values.push({val: '!no-unauthenticated'})
|
||||
}
|
||||
|
||||
// delete if no longer needed
|
||||
if (existing.labels.values.length === 0) {
|
||||
delete existing.labels
|
||||
}
|
||||
return existing
|
||||
},
|
||||
checkCommitted: res => {
|
||||
const exists = !!res.data.labels?.some(
|
||||
l => l.val === '!no-unauthenticated',
|
||||
)
|
||||
return exists === wasAdded
|
||||
},
|
||||
})
|
||||
}, [updateProfile, profile])
|
||||
|
||||
return (
|
||||
<View style={[a.pt_sm]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
|
||||
<Toggle.Item
|
||||
disabled={!canToggle}
|
||||
value={isOptedOut}
|
||||
onChange={onToggleOptOut}
|
||||
name="logged_out_visibility"
|
||||
style={a.flex_1}
|
||||
label={_(
|
||||
msg`Discourage apps from showing my account to logged-out users`,
|
||||
)}>
|
||||
<Toggle.Switch />
|
||||
<Toggle.Label style={[a.text_md, a.flex_1]}>
|
||||
<Trans>
|
||||
Discourage apps from showing my account to logged-out users
|
||||
</Trans>
|
||||
</Toggle.Label>
|
||||
</Toggle.Item>
|
||||
|
||||
{updateProfile.isPending && <Loader />}
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_md, a.gap_md, {paddingLeft: 38}]}>
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
Bluesky will not show your profile and posts to logged-out users.
|
||||
Other apps may not honor this request. This does not make your
|
||||
account private.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
Note: Bluesky is an open and public network. This setting only
|
||||
limits the visibility of your content on the Bluesky app and
|
||||
website, and other apps may not respect this setting. Your content
|
||||
may still be shown to logged-out users by other apps and websites.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<InlineLink to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
|
||||
<Trans>Learn more about what is public on Bluesky.</Trans>
|
||||
</InlineLink>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -56,7 +56,9 @@ export function AdultContentEnabledPref({
|
||||
|
||||
try {
|
||||
mutate({
|
||||
enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
|
||||
enabled: !(
|
||||
variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled
|
||||
),
|
||||
})
|
||||
} catch (e) {
|
||||
Toast.show(
|
||||
@@ -75,7 +77,10 @@ export function AdultContentEnabledPref({
|
||||
<Toggle.Item
|
||||
name={_(msg`Enable adult content in your feeds`)}
|
||||
label={_(msg`Enable adult content in your feeds`)}
|
||||
value={variables?.enabled ?? preferences?.adultContentEnabled}
|
||||
value={
|
||||
variables?.enabled ??
|
||||
preferences?.moderationPrefs.adultContentEnabled
|
||||
}
|
||||
onChange={onToggleAdultContent}>
|
||||
<View
|
||||
style={[
|
||||
@@ -85,7 +90,9 @@ export function AdultContentEnabledPref({
|
||||
a.align_center,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Text style={[a.font_bold]}>Enable Adult Content</Text>
|
||||
<Text style={[a.font_bold]}>
|
||||
<Trans>Enable Adult Content</Trans>
|
||||
</Text>
|
||||
<Toggle.Switch />
|
||||
</View>
|
||||
</Toggle.Item>
|
||||
@@ -106,7 +113,9 @@ export function AdultContentEnabledPref({
|
||||
)}
|
||||
|
||||
<Prompt.Outer control={prompt}>
|
||||
<Prompt.Title>Adult Content</Prompt.Title>
|
||||
<Prompt.Title>
|
||||
<Trans>Adult Content</Trans>
|
||||
</Prompt.Title>
|
||||
<Prompt.Description>
|
||||
<Trans>
|
||||
Due to Apple policies, adult content can only be enabled on the web
|
||||
@@ -114,7 +123,9 @@ export function AdultContentEnabledPref({
|
||||
</Trans>
|
||||
</Prompt.Description>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action onPress={() => prompt.close()}>OK</Prompt.Action>
|
||||
<Prompt.Action onPress={() => prompt.close()}>
|
||||
<Trans>OK</Trans>
|
||||
</Prompt.Action>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
|
||||
@@ -1,40 +1,51 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {LabelPreference} from '@atproto/api'
|
||||
import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import Animated, {Easing, Layout, FadeIn} from 'react-native-reanimated'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
CONFIGURABLE_LABEL_GROUPS,
|
||||
ConfigurableLabelGroup,
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
|
||||
export function ModerationOption({
|
||||
labelGroup,
|
||||
isMounted,
|
||||
labelValueDefinition,
|
||||
disabled,
|
||||
}: {
|
||||
labelGroup: ConfigurableLabelGroup
|
||||
isMounted: React.MutableRefObject<boolean>
|
||||
labelValueDefinition: InterpretedLabelValueDefinition
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup]
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const label = labelValueDefinition.identifier
|
||||
const visibility =
|
||||
variables?.visibility ?? preferences?.contentLabels?.[labelGroup]
|
||||
variables?.visibility ?? preferences?.moderationPrefs.labels?.[label]
|
||||
|
||||
const allLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings =
|
||||
labelValueDefinition.identifier in allLabelStrings
|
||||
? allLabelStrings[labelValueDefinition.identifier]
|
||||
: {
|
||||
name: labelValueDefinition.identifier,
|
||||
description: `Labeled "${labelValueDefinition.identifier}"`,
|
||||
}
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(vis: string[]) => {
|
||||
mutate({labelGroup, visibility: vis[0] as LabelPreference})
|
||||
mutate({
|
||||
label,
|
||||
visibility: vis[0] as LabelPreference,
|
||||
labelerDid: undefined,
|
||||
})
|
||||
},
|
||||
[mutate, labelGroup],
|
||||
[mutate, label],
|
||||
)
|
||||
|
||||
const labels = {
|
||||
@@ -44,7 +55,7 @@ export function ModerationOption({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
@@ -52,33 +63,37 @@ export function ModerationOption({
|
||||
a.py_xs,
|
||||
a.px_xs,
|
||||
a.align_center,
|
||||
]}
|
||||
layout={Layout.easing(Easing.ease).duration(200)}
|
||||
entering={isMounted.current ? FadeIn : undefined}>
|
||||
<View style={[a.gap_xs, {width: '50%'}]}>
|
||||
<Text style={[a.font_bold]}>{groupInfo.title}</Text>
|
||||
]}>
|
||||
<View style={[a.gap_xs, a.flex_1]}>
|
||||
<Text style={[a.font_bold]}>{labelStrings.name}</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{groupInfo.subtitle}
|
||||
{labelStrings.description}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.justify_center, {minHeight: 35}]}>
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`,
|
||||
)}
|
||||
values={[visibility ?? 'hide']}
|
||||
onChange={onChange}>
|
||||
<ToggleButton.Button name="hide" label={labels.hide}>
|
||||
{labels.hide}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="warn" label={labels.warn}>
|
||||
{labels.warn}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="ignore" label={labels.show}>
|
||||
{labels.show}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
<View style={[a.justify_center, {minHeight: 40}]}>
|
||||
{disabled ? (
|
||||
<Text style={[a.font_bold]}>
|
||||
<Trans>Hide</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||
)}
|
||||
values={[visibility ?? 'hide']}
|
||||
onChange={onChange}>
|
||||
<ToggleButton.Button name="ignore" label={labels.show}>
|
||||
{labels.show}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="warn" label={labels.warn}>
|
||||
{labels.warn}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="hide" label={labels.hide}>
|
||||
{labels.hide}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,15 +2,10 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import Animated, {Easing, Layout} from 'react-native-reanimated'
|
||||
import {LABELS} from '@atproto/api'
|
||||
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {
|
||||
configurableAdultLabelGroups,
|
||||
configurableOtherLabelGroups,
|
||||
usePreferencesSetAdultContentMutation,
|
||||
} from 'state/queries/preferences'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
@@ -28,14 +23,6 @@ import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/Adult
|
||||
import {Context} from '#/screens/Onboarding/state'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
|
||||
function AnimatedDivider() {
|
||||
return (
|
||||
<Animated.View layout={Layout.easing(Easing.ease).duration(200)}>
|
||||
<Divider />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepModeration() {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
@@ -53,7 +40,7 @@ export function StepModeration() {
|
||||
|
||||
const adultContentEnabled = !!(
|
||||
(variables && variables.enabled) ||
|
||||
(!variables && preferences?.adultContentEnabled)
|
||||
(!variables && preferences?.moderationPrefs.adultContentEnabled)
|
||||
)
|
||||
|
||||
const onContinue = React.useCallback(() => {
|
||||
@@ -87,22 +74,19 @@ export function StepModeration() {
|
||||
<AdultContentEnabledPref mutate={mutate} variables={variables} />
|
||||
|
||||
<View style={[a.gap_sm, a.w_full]}>
|
||||
{adultContentEnabled &&
|
||||
configurableAdultLabelGroups.map((g, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index === 0 && <AnimatedDivider />}
|
||||
<ModerationOption labelGroup={g} isMounted={isMounted} />
|
||||
<AnimatedDivider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{configurableOtherLabelGroups.map((g, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{!adultContentEnabled && index === 0 && <AnimatedDivider />}
|
||||
<ModerationOption labelGroup={g} isMounted={isMounted} />
|
||||
<AnimatedDivider />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<ModerationOption
|
||||
labelValueDefinition={LABELS.porn}
|
||||
disabled={!adultContentEnabled}
|
||||
/>
|
||||
<ModerationOption
|
||||
labelValueDefinition={LABELS.sexual}
|
||||
disabled={!adultContentEnabled}
|
||||
/>
|
||||
<ModerationOption
|
||||
labelValueDefinition={LABELS['graphic-media']}
|
||||
disabled={!adultContentEnabled}
|
||||
/>
|
||||
<ModerationOption labelValueDefinition={LABELS.nudity} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function SuggestedAccountCard({
|
||||
<UserAvatar
|
||||
size={48}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1]}>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function StepSuggestedAccounts() {
|
||||
return aggregateInterestItems(
|
||||
state.interestsStepResults.selectedInterests,
|
||||
state.interestsStepResults.apiResponse.suggestedAccountDids,
|
||||
state.interestsStepResults.apiResponse.suggestedAccountDids.default,
|
||||
state.interestsStepResults.apiResponse.suggestedAccountDids.default || [],
|
||||
)
|
||||
}, [state.interestsStepResults])
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
|
||||
import {aggregateInterestItems} from '#/screens/Onboarding/util'
|
||||
import {IconCircle} from '#/components/IconCircle'
|
||||
import {IS_PROD_SERVICE} from 'lib/constants'
|
||||
import {IS_TEST_USER} from 'lib/constants'
|
||||
import {useSession} from 'state/session'
|
||||
|
||||
export function StepTopicalFeeds() {
|
||||
@@ -33,14 +33,14 @@ export function StepTopicalFeeds() {
|
||||
const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const suggestedFeedUris = React.useMemo(() => {
|
||||
if (!IS_PROD_SERVICE(currentAccount?.service)) return []
|
||||
if (IS_TEST_USER(currentAccount?.handle)) return []
|
||||
return aggregateInterestItems(
|
||||
state.interestsStepResults.selectedInterests,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris.default,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris.default || [],
|
||||
).slice(0, 10)
|
||||
}, [
|
||||
currentAccount?.service,
|
||||
currentAccount?.handle,
|
||||
state.interestsStepResults.apiResponse.suggestedFeedUris,
|
||||
state.interestsStepResults.selectedInterests,
|
||||
])
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
|
||||
export function ErrorState({error}: {error: string}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Home')
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<View style={[a.px_xl]}>
|
||||
<CircleInfo width={48} style={[t.atoms.text_contrast_low]} />
|
||||
|
||||
<Text style={[a.text_xl, a.font_bold, a.pb_md, a.pt_xl]}>
|
||||
<Trans>Hmmmm, we couldn't load that moderation service.</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_normal,
|
||||
a.pb_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
This moderation service is unavailable. See below for more details. If
|
||||
this issue persists, contact us.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.rounded_md,
|
||||
a.mb_2xl,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.text_md, a.leading_normal]}>{error}</Text>
|
||||
</View>
|
||||
|
||||
<View style={{flexDirection: 'row'}}>
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
label={_(msg`Go Back`)}
|
||||
accessibilityHint="Return to previous page"
|
||||
onPress={onPressBack}>
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ProfileHeaderDisplayName({
|
||||
profile,
|
||||
moderation,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
moderation: ModerationDecision
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View pointerEvents="none">
|
||||
<Text
|
||||
testID="profileHeaderDisplayName"
|
||||
style={[t.atoms.text, a.text_4xl, {fontWeight: '500'}]}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {isInvalidHandle} from 'lib/strings/handles'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ProfileHeaderHandle({
|
||||
profile,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const invalidHandle = isInvalidHandle(profile.handle)
|
||||
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none">
|
||||
{profile.viewer?.followedBy && !blockHide ? (
|
||||
<View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
|
||||
<Text style={[t.atoms.text, a.text_sm]}>
|
||||
<Trans>Follows you</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
<Text
|
||||
style={[
|
||||
invalidHandle
|
||||
? [
|
||||
a.border,
|
||||
a.text_xs,
|
||||
a.px_sm,
|
||||
a.py_xs,
|
||||
a.rounded_xs,
|
||||
{borderColor: t.palette.contrast_200},
|
||||
]
|
||||
: [a.text_md, t.atoms.text_contrast_medium],
|
||||
web({wordBreak: 'break-all'}),
|
||||
]}>
|
||||
{invalidHandle ? <Trans>⚠Invalid Handle</Trans> : `@${profile.handle}`}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {formatCount} from 'view/com/util/numeric/format'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
|
||||
export function ProfileHeaderMetrics({
|
||||
profile,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.flex_row, a.gap_sm, a.align_center, a.pb_md]}
|
||||
pointerEvents="box-none">
|
||||
<InlineLink
|
||||
testID="profileHeaderFollowersButton"
|
||||
style={[a.flex_row, t.atoms.text]}
|
||||
to={makeProfileLink(profile, 'followers')}
|
||||
label={`${followers} ${pluralizedFollowers}`}>
|
||||
<Text style={[a.font_bold, a.text_md]}>{followers} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
|
||||
{pluralizedFollowers}
|
||||
</Text>
|
||||
</InlineLink>
|
||||
<InlineLink
|
||||
testID="profileHeaderFollowsButton"
|
||||
style={[a.flex_row, t.atoms.text]}
|
||||
to={makeProfileLink(profile, 'follows')}
|
||||
label={_(msg`${following} following`)}>
|
||||
<Trans>
|
||||
<Text style={[a.font_bold, a.text_md]}>{following} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
|
||||
following
|
||||
</Text>
|
||||
</Trans>
|
||||
</InlineLink>
|
||||
<Text style={[a.font_bold, t.atoms.text, a.text_md]}>
|
||||
{formatCount(profile.postsCount || 0)}{' '}
|
||||
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
|
||||
{pluralize(profile.postsCount || 0, 'post')}
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import React, {memo, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyLabelerDefs,
|
||||
ModerationOpts,
|
||||
moderateProfile,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useSession} from '#/state/session'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
|
||||
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||
import {logger} from '#/logger'
|
||||
import {Haptics} from '#/lib/haptics'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {isAppLabeler} from '#/lib/moderation'
|
||||
|
||||
import {atoms as a, useTheme, tokens} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ProfileHeaderShell} from './Shell'
|
||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||
import {ProfileHeaderDisplayName} from './DisplayName'
|
||||
import {ProfileHeaderHandle} from './Handle'
|
||||
import {ProfileHeaderMetrics} from './Metrics'
|
||||
import {
|
||||
Heart2_Stroke2_Corner0_Rounded as Heart,
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
||||
} from '#/components/icons/Heart2'
|
||||
import {DialogOuterProps} from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Link} from '#/components/Link'
|
||||
|
||||
interface Props {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
descriptionRT: RichTextAPI | null
|
||||
moderationOpts: ModerationOpts
|
||||
hideBackButton?: boolean
|
||||
isPlaceholderProfile?: boolean
|
||||
}
|
||||
|
||||
let ProfileHeaderLabeler = ({
|
||||
profile: profileUnshadowed,
|
||||
labeler,
|
||||
descriptionRT,
|
||||
moderationOpts,
|
||||
hideBackButton = false,
|
||||
isPlaceholderProfile,
|
||||
}: Props): React.ReactNode => {
|
||||
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||
useProfileShadow(profileUnshadowed)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const {track} = useAnalytics()
|
||||
const cantSubscribePrompt = Prompt.usePromptControl()
|
||||
const isSelf = currentAccount?.did === profile.did
|
||||
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutateAsync: toggleSubscription, variables} =
|
||||
useLabelerSubscriptionMutation()
|
||||
const isSubscribed =
|
||||
variables?.subscribe ??
|
||||
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
|
||||
const canSubscribe =
|
||||
isSubscribed ||
|
||||
(preferences ? preferences?.moderationPrefs.labelers.length < 9 : false)
|
||||
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
|
||||
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
|
||||
useUnlikeMutation()
|
||||
const [likeUri, setLikeUri] = React.useState<string>(
|
||||
labeler.viewer?.like || '',
|
||||
)
|
||||
const [likeCount, setLikeCount] = React.useState(labeler.likeCount || 0)
|
||||
|
||||
const onToggleLiked = React.useCallback(async () => {
|
||||
if (!labeler) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
Haptics.default()
|
||||
|
||||
if (likeUri) {
|
||||
await unlikeMod({uri: likeUri})
|
||||
track('CustomFeed:Unlike')
|
||||
setLikeCount(c => c - 1)
|
||||
setLikeUri('')
|
||||
} else {
|
||||
const res = await likeMod({uri: labeler.uri, cid: labeler.cid})
|
||||
track('CustomFeed:Like')
|
||||
setLikeCount(c => c + 1)
|
||||
setLikeUri(res.uri)
|
||||
}
|
||||
} catch (e: any) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`There was an an issue contacting the server, please check your internet connection and try again.`,
|
||||
),
|
||||
)
|
||||
logger.error(`Failed to toggle labeler like`, {message: e.message})
|
||||
}
|
||||
}, [labeler, likeUri, likeMod, unlikeMod, track, _])
|
||||
|
||||
const onPressEditProfile = React.useCallback(() => {
|
||||
track('ProfileHeader:EditProfileButtonClicked')
|
||||
openModal({
|
||||
name: 'edit-profile',
|
||||
profile,
|
||||
})
|
||||
}, [track, openModal, profile])
|
||||
|
||||
const onPressSubscribe = React.useCallback(async () => {
|
||||
if (!canSubscribe) {
|
||||
cantSubscribePrompt.open()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await toggleSubscription({
|
||||
did: profile.did,
|
||||
subscribe: !isSubscribed,
|
||||
})
|
||||
} catch (e: any) {
|
||||
// setSubscriptionError(e.message)
|
||||
logger.error(`Failed to subscribe to labeler`, {message: e.message})
|
||||
}
|
||||
}, [
|
||||
toggleSubscription,
|
||||
isSubscribed,
|
||||
profile,
|
||||
canSubscribe,
|
||||
cantSubscribePrompt,
|
||||
])
|
||||
|
||||
const isMe = React.useMemo(
|
||||
() => currentAccount?.did === profile.did,
|
||||
[currentAccount, profile],
|
||||
)
|
||||
|
||||
return (
|
||||
<ProfileHeaderShell
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none">
|
||||
<View
|
||||
style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]}
|
||||
pointerEvents="box-none">
|
||||
{isMe ? (
|
||||
<Button
|
||||
testID="profileHeaderEditProfileButton"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
onPress={onPressEditProfile}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={a.rounded_full}>
|
||||
<ButtonText>
|
||||
<Trans>Edit Profile</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : !isAppLabeler(profile.did) ? (
|
||||
<>
|
||||
<Button
|
||||
testID="toggleSubscribeBtn"
|
||||
label={
|
||||
isSubscribed
|
||||
? _(msg`Unsubscribe from this labeler`)
|
||||
: _(msg`Subscribe to this labeler`)
|
||||
}
|
||||
disabled={!hasSession}
|
||||
onPress={onPressSubscribe}>
|
||||
{state => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingVertical: 12,
|
||||
backgroundColor:
|
||||
isSubscribed || !canSubscribe
|
||||
? state.hovered || state.pressed
|
||||
? t.palette.contrast_50
|
||||
: t.palette.contrast_25
|
||||
: state.hovered || state.pressed
|
||||
? tokens.color.temp_purple_dark
|
||||
: tokens.color.temp_purple,
|
||||
},
|
||||
a.px_lg,
|
||||
a.rounded_sm,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
{
|
||||
color: canSubscribe
|
||||
? isSubscribed
|
||||
? t.palette.contrast_700
|
||||
: t.palette.white
|
||||
: t.palette.contrast_400,
|
||||
},
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
]}>
|
||||
{isSubscribed ? (
|
||||
<Trans>Unsubscribe</Trans>
|
||||
) : (
|
||||
<Trans>Subscribe to Labeler</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<ProfileMenu profile={profile} />
|
||||
</View>
|
||||
<View style={[a.flex_col, a.gap_xs, a.pb_md]}>
|
||||
<ProfileHeaderDisplayName profile={profile} moderation={moderation} />
|
||||
<ProfileHeaderHandle profile={profile} />
|
||||
</View>
|
||||
{!isPlaceholderProfile && (
|
||||
<>
|
||||
{isSelf && <ProfileHeaderMetrics profile={profile} />}
|
||||
{descriptionRT && !moderation.ui('profileView').blur ? (
|
||||
<View pointerEvents="auto">
|
||||
<RichText
|
||||
testID="profileHeaderDescription"
|
||||
style={[a.text_md]}
|
||||
numberOfLines={15}
|
||||
value={descriptionRT}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
{!isAppLabeler(profile.did) && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}>
|
||||
<Button
|
||||
testID="toggleLikeBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
shape="round"
|
||||
label={_(msg`Like this feed`)}
|
||||
disabled={!hasSession || isLikePending || isUnlikePending}
|
||||
onPress={onToggleLiked}>
|
||||
{likeUri ? (
|
||||
<HeartFilled fill={t.palette.negative_400} />
|
||||
) : (
|
||||
<Heart fill={t.atoms.text_contrast_medium.color} />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{typeof likeCount === 'number' && (
|
||||
<Link
|
||||
to={{
|
||||
screen: 'ProfileLabelerLikedBy',
|
||||
params: {
|
||||
name: labeler.creator.handle || labeler.creator.did,
|
||||
},
|
||||
}}
|
||||
size="tiny"
|
||||
label={_(
|
||||
msg`Liked by ${likeCount} ${pluralize(
|
||||
likeCount,
|
||||
'user',
|
||||
)}`,
|
||||
)}>
|
||||
{({hovered, focused, pressed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_medium,
|
||||
(hovered || focused || pressed) &&
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>
|
||||
Liked by {likeCount} {pluralize(likeCount, 'user')}
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<CantSubscribePrompt control={cantSubscribePrompt} />
|
||||
</ProfileHeaderShell>
|
||||
)
|
||||
}
|
||||
ProfileHeaderLabeler = memo(ProfileHeaderLabeler)
|
||||
export {ProfileHeaderLabeler}
|
||||
|
||||
function CantSubscribePrompt({
|
||||
control,
|
||||
}: {
|
||||
control: DialogOuterProps['control']
|
||||
}) {
|
||||
return (
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.Title>Unable to subscribe</Prompt.Title>
|
||||
<Prompt.Description>
|
||||
<Trans>
|
||||
We're sorry! You can only subscribe to ten labelers, and you've
|
||||
reached your limit of ten.
|
||||
</Trans>
|
||||
</Prompt.Description>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action onPress={control.close}>OK</Prompt.Action>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user