Merge remote-tracking branch 'origin/main' into hailey/onboarding-profile

# Conflicts:
#	src/screens/Onboarding/StepModeration/index.tsx
This commit is contained in:
Hailey
2024-03-19 14:10:22 -07:00
249 changed files with 17056 additions and 10287 deletions
+28 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+10
View File
@@ -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,
},
+4
View File
@@ -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,
},
+3
View File
@@ -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
View File
@@ -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>
)
+1 -1
View File
@@ -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},
]}
/>
)}
+8 -2
View File
@@ -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
}
+90
View File
@@ -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>
)
}
+27
View File
@@ -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} />
)
}
+109
View File
@@ -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>
)
}
+131
View File
@@ -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
}
+1 -1
View File
@@ -251,7 +251,7 @@ export function InlineLink({
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const flattenedStyle = flatten(style)
const flattenedStyle = flatten(style) || {}
return (
<Text
+67 -115
View File
@@ -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}
/>
)
}
}
+4 -2
View File
@@ -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>
)
}
+1 -1
View File
@@ -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>
)
}
+264
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
+95
View File
@@ -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} />
}
+15
View File
@@ -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
}
}
+4 -2
View File
@@ -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>
</>
)}
+1 -1
View File
@@ -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(
+7 -2
View File
@@ -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
+41 -33
View File
@@ -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>
+1 -1
View File
@@ -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],
)
+2 -2
View File
@@ -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
+23 -17
View File
@@ -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
+5 -3
View File
@@ -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
+15
View File
@@ -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
}
+5
View File
@@ -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',
})
+5
View File
@@ -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',
})
+8
View File
@@ -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',
})
+5
View File
@@ -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',
})
+5
View File
@@ -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',
})
+5
View File
@@ -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',
})
+5
View File
@@ -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',
})
+182
View File
@@ -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>
)
}
+83
View File
@@ -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>
)
}
+66
View File
@@ -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} />
</>
)
}
@@ -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} />
</>
)
}
+171
View File
@@ -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)
})
})
})
+4
View File
@@ -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
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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`)
}
}
+100
View File
@@ -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])
}
+94
View File
@@ -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,
}
}, [_])
}
+2
View File
@@ -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
}
+20
View File
@@ -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]))
},
}
+2
View File
@@ -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
+3
View File
@@ -2,6 +2,9 @@ export type LogEvents = {
init: {
initMs: number
}
'notifications:openApp': {}
'state:background': {}
'state:foreground': {}
'feed:endReached': {
feedType: string
itemCount: number
+19 -1
View File
@@ -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 -2
View File
@@ -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()
+11 -2
View File
@@ -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}`,
}
}
}
+26 -12
View File
@@ -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
View File
@@ -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,
+2
View File
@@ -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':
+5
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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"
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+81 -248
View File
@@ -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!"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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',
+2 -2
View File
@@ -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>
+556
View File
@@ -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>
)
}
+16 -32
View File
@@ -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()
+4 -4
View File
@@ -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,
])
+72
View File
@@ -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>
)
}
+46
View File
@@ -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>
)
}
+61
View File
@@ -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