Merge branch 'main' into web-layout

This commit is contained in:
Dan Abramov
2024-01-11 22:47:17 +00:00
245 changed files with 21884 additions and 4478 deletions
+204
View File
@@ -0,0 +1,204 @@
import React from 'react'
import {Pressable, Text, PressableProps, TextProps} from 'react-native'
import * as tokens from '#/alf/tokens'
import {atoms} from '#/alf'
export type ButtonType =
| 'primary'
| 'secondary'
| 'tertiary'
| 'positive'
| 'negative'
export type ButtonSize = 'small' | 'large'
export type VariantProps = {
type?: ButtonType
size?: ButtonSize
}
type ButtonState = {
pressed: boolean
hovered: boolean
focused: boolean
}
export type ButtonProps = Omit<PressableProps, 'children'> &
VariantProps & {
children:
| ((props: {
state: ButtonState
type?: ButtonType
size?: ButtonSize
}) => React.ReactNode)
| React.ReactNode
| string
}
export type ButtonTextProps = TextProps & VariantProps
export function Button({children, style, type, size, ...rest}: ButtonProps) {
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles = []
const hoverStyles = []
switch (type) {
case 'primary':
baseStyles.push({
backgroundColor: tokens.color.blue_500,
})
break
case 'secondary':
baseStyles.push({
backgroundColor: tokens.color.gray_200,
})
hoverStyles.push({
backgroundColor: tokens.color.gray_100,
})
break
default:
}
switch (size) {
case 'large':
baseStyles.push(
atoms.py_md,
atoms.px_xl,
atoms.rounded_md,
atoms.gap_sm,
)
break
case 'small':
baseStyles.push(
atoms.py_sm,
atoms.px_md,
atoms.rounded_sm,
atoms.gap_xs,
)
break
default:
}
return {
baseStyles,
hoverStyles,
}
}, [type, size])
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const onPressIn = React.useCallback(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
return (
<Pressable
{...rest}
style={state => [
atoms.flex_row,
atoms.align_center,
...baseStyles,
...(state.hovered ? hoverStyles : []),
typeof style === 'function' ? style(state) : style,
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}>
{typeof children === 'string' ? (
<ButtonText type={type} size={size}>
{children}
</ButtonText>
) : typeof children === 'function' ? (
children({state, type, size})
) : (
children
)}
</Pressable>
)
}
export function ButtonText({
children,
style,
type,
size,
...rest
}: ButtonTextProps) {
const textStyles = React.useMemo(() => {
const base = []
switch (type) {
case 'primary':
base.push({color: tokens.color.white})
break
case 'secondary':
base.push({
color: tokens.color.gray_700,
})
break
default:
}
switch (size) {
case 'small':
base.push(atoms.text_sm, {paddingBottom: 1})
break
case 'large':
base.push(atoms.text_md, {paddingBottom: 1})
break
default:
}
return base
}, [type, size])
return (
<Text
{...rest}
style={[
atoms.flex_1,
atoms.font_semibold,
atoms.text_center,
...textStyles,
style,
]}>
{children}
</Text>
)
}
+104
View File
@@ -0,0 +1,104 @@
import React from 'react'
import {Text as RNText, TextProps} from 'react-native'
import {useTheme, atoms, web} from '#/alf'
export function Text({style, ...rest}: TextProps) {
const t = useTheme()
return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} />
}
export function H1({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 1,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_xl, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H2({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 2,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_lg, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H3({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 3,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_md, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H4({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 4,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_sm, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H5({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 5,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_xs, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H6({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 6,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_xxs, atoms.font_bold, t.atoms.text, style]}
/>
)
}
+2 -2
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View, Pressable} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {useNavigation} from '@react-navigation/native'
import {isIOS, isNative} from 'platform/detection'
@@ -119,7 +119,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
}}
onPress={onPressSearch}>
<Text type="lg-bold" style={[pal.text]}>
Search{' '}
<Trans>Search</Trans>{' '}
</Text>
<FontAwesomeIcon
icon="search"
+1 -1
View File
@@ -74,7 +74,7 @@ export const SplashScreen = ({
// TODO: web accessibility
accessibilityRole="button">
<Text style={[s.white, styles.btnLabel]}>
Create a new account
<Trans>Create a new account</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
+54 -55
View File
@@ -1,7 +1,6 @@
import React from 'react'
import {
ActivityIndicator,
KeyboardAvoidingView,
ScrollView,
StyleSheet,
TouchableOpacity,
@@ -28,6 +27,7 @@ import {IS_PROD} from '#/lib/constants'
import {Step1} from './Step1'
import {Step2} from './Step2'
import {Step3} from './Step3'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics()
@@ -38,6 +38,7 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {createAccount} = useSessionApi()
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const {isTabletOrDesktop} = useWebMediaQueries()
React.useEffect(() => {
screen('CreateAccount')
@@ -120,64 +121,62 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
title={_(msg`Create Account`)}
description={_(msg`We're so excited to have you join us!`)}>
<ScrollView testID="createAccount" style={pal.view}>
<KeyboardAvoidingView behavior="padding">
<View style={styles.stepContainer}>
{uiState.step === 1 && (
<Step1 uiState={uiState} uiDispatch={uiDispatch} />
)}
{uiState.step === 2 && (
<Step2 uiState={uiState} uiDispatch={uiDispatch} />
)}
{uiState.step === 3 && (
<Step3 uiState={uiState} uiDispatch={uiDispatch} />
)}
</View>
<View style={[s.flexRow, s.pl20, s.pr20]}>
<View style={styles.stepContainer}>
{uiState.step === 1 && (
<Step1 uiState={uiState} uiDispatch={uiDispatch} />
)}
{uiState.step === 2 && (
<Step2 uiState={uiState} uiDispatch={uiDispatch} />
)}
{uiState.step === 3 && (
<Step3 uiState={uiState} uiDispatch={uiDispatch} />
)}
</View>
<View style={[s.flexRow, s.pl20, s.pr20]}>
<TouchableOpacity
onPress={onPressBackInner}
testID="backBtn"
accessibilityRole="button">
<Text type="xl" style={pal.link}>
<Trans>Back</Trans>
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{uiState.canNext ? (
<TouchableOpacity
onPress={onPressBackInner}
testID="backBtn"
testID="nextBtn"
onPress={onPressNext}
accessibilityRole="button">
<Text type="xl" style={pal.link}>
<Trans>Back</Trans>
{uiState.isProcessing ? (
<ActivityIndicator />
) : (
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
)}
</TouchableOpacity>
) : serviceInfoError ? (
<TouchableOpacity
testID="retryConnectBtn"
onPress={() => refetchServiceInfo()}
accessibilityRole="button"
accessibilityLabel={_(msg`Retry`)}
accessibilityHint=""
accessibilityLiveRegion="polite">
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Retry</Trans>
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{uiState.canNext ? (
<TouchableOpacity
testID="nextBtn"
onPress={onPressNext}
accessibilityRole="button">
{uiState.isProcessing ? (
<ActivityIndicator />
) : (
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
)}
</TouchableOpacity>
) : serviceInfoError ? (
<TouchableOpacity
testID="retryConnectBtn"
onPress={() => refetchServiceInfo()}
accessibilityRole="button"
accessibilityLabel={_(msg`Retry`)}
accessibilityHint=""
accessibilityLiveRegion="polite">
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Retry</Trans>
</Text>
</TouchableOpacity>
) : serviceInfoIsFetching ? (
<>
<ActivityIndicator color="#fff" />
<Text type="xl" style={[pal.text, s.pr5]}>
<Trans>Connecting...</Trans>
</Text>
</>
) : undefined}
</View>
<View style={s.footerSpacer} />
</KeyboardAvoidingView>
) : serviceInfoIsFetching ? (
<>
<ActivityIndicator color="#fff" />
<Text type="xl" style={[pal.text, s.pr5]}>
<Trans>Connecting...</Trans>
</Text>
</>
) : undefined}
</View>
<View style={{height: isTabletOrDesktop ? 50 : 400}} />
</ScrollView>
</LoggedOutLayout>
)
+3 -2
View File
@@ -77,7 +77,7 @@ export function Step1({
value={uiState.serviceUrl}
editable
onChange={onChangeServiceUrl}
accessibilityHint="Input hosting provider address"
accessibilityHint={_(msg`Input hosting provider address`)}
accessibilityLabel={_(msg`Hosting provider address`)}
accessibilityLabelledBy="addressProvider"
/>
@@ -125,6 +125,7 @@ function Option({
}>) {
const theme = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const circleFillStyle = React.useMemo(
() => ({
backgroundColor: theme.palette.primary.background,
@@ -139,7 +140,7 @@ function Option({
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint={`Sets hosting provider to ${label}`}>
accessibilityHint={_(msg`Sets hosting provider to ${label}`)}>
<View style={styles.optionHeading}>
<View style={[styles.circle, pal.border]}>
{isSelected ? (
+32 -7
View File
@@ -13,6 +13,17 @@ import {isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {logger} from '#/logger'
function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') {
logger.error(`Create account: handled invalid date for birthDate`, {
hasDate: !!date,
})
return new Date()
}
return date
}
/** STEP 2: Your account
* @field Invite code or waitlist
@@ -38,6 +49,10 @@ export function Step2({
openModal({name: 'waitlist'})
}, [openModal])
const birthDate = React.useMemo(() => {
return sanitizeDate(uiState.birthDate)
}, [uiState.birthDate])
return (
<View>
<StepHeader step="2" title={_(msg`Your account`)} />
@@ -45,7 +60,7 @@ export function Step2({
{uiState.isInviteCodeRequired && (
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
Invite code
<Trans>Invite code</Trans>
</Text>
<TextInput
testID="inviteCodeInput"
@@ -55,14 +70,17 @@ export function Step2({
editable
onChange={value => uiDispatch({type: 'set-invite-code', value})}
accessibilityLabel={_(msg`Invite code`)}
accessibilityHint="Input invite code to proceed"
accessibilityHint={_(msg`Input invite code to proceed`)}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
)}
{!uiState.inviteCode && uiState.isInviteCodeRequired ? (
<Text style={[s.alignBaseline, pal.text]}>
Don't have an invite code?{' '}
<Trans>Don't have an invite code?</Trans>{' '}
<TouchableWithoutFeedback
onPress={onPressWaitlist}
accessibilityLabel={_(msg`Join the waitlist.`)}
@@ -88,8 +106,11 @@ export function Step2({
editable
onChange={value => uiDispatch({type: 'set-email', value})}
accessibilityLabel={_(msg`Email`)}
accessibilityHint="Input email for Bluesky waitlist"
accessibilityHint={_(msg`Input email for Bluesky waitlist`)}
accessibilityLabelledBy="email"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
@@ -109,8 +130,11 @@ export function Step2({
secureTextEntry
onChange={value => uiDispatch({type: 'set-password', value})}
accessibilityLabel={_(msg`Password`)}
accessibilityHint="Set password"
accessibilityHint={_(msg`Set password`)}
accessibilityLabelledBy="password"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
@@ -122,14 +146,15 @@ export function Step2({
<Trans>Your birth date</Trans>
</Text>
<DateInput
handleAsUTC
testID="birthdayInput"
value={uiState.birthDate}
value={birthDate}
onChange={value => uiDispatch({type: 'set-birth-date', value})}
buttonType="default-light"
buttonStyle={[pal.border, styles.dateInputButton]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
accessibilityHint="Enter your birth date"
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
/>
</View>
+1 -1
View File
@@ -36,7 +36,7 @@ export function Step3({
onChange={value => uiDispatch({type: 'set-handle', value})}
// TODO: Add explicit text label
accessibilityLabel={_(msg`User handle`)}
accessibilityHint="Input your user handle"
accessibilityHint={_(msg`Input your user handle`)}
/>
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
<Trans>Your full handle will be</Trans>{' '}
+6 -1
View File
@@ -2,13 +2,18 @@ import React from 'react'
import {StyleSheet, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {Trans} from '@lingui/macro'
export function StepHeader({step, title}: {step: string; title: string}) {
const pal = usePalette('default')
return (
<View style={styles.container}>
<Text type="lg" style={[pal.textLight]}>
{step === '3' ? 'Last step!' : <>Step {step} of 3</>}
{step === '3' ? (
<Trans>Last step!</Trans>
) : (
<Trans>Step {step} of 3</Trans>
)}
</Text>
<Text style={[pal.text]} type="title-xl">
{title}
+7 -1
View File
@@ -136,7 +136,13 @@ export async function submit({
msg`Invite code not accepted. Check that you input it correctly and try again.`,
)
}
logger.error('Failed to create account', {error: e})
if ([400, 429].includes(e.status)) {
logger.warn('Failed to create account', {error: e})
} else {
logger.error(`Failed to create account (${e.status} status)`, {error: e})
}
uiDispatch({type: 'set-processing', value: false})
uiDispatch({type: 'set-error', value: cleanError(errMsg)})
throw e
@@ -42,7 +42,7 @@ function AccountItem({
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={_(msg`Sign in as ${account.handle}`)}
accessibilityHint="Double tap to sign in">
accessibilityHint={_(msg`Double tap to sign in`)}>
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<View style={s.p10}>
<UserAvatar avatar={profile?.avatar} size={30} />
@@ -95,19 +95,19 @@ export const ChooseAccountForm = ({
if (account.accessJwt) {
if (account.did === currentAccount?.did) {
setShowLoggedOut(false)
Toast.show(`Already signed in as @${account.handle}`)
Toast.show(_(msg`Already signed in as @${account.handle}`))
} else {
await initSession(account)
track('Sign In', {resumedSession: true})
setTimeout(() => {
Toast.show(`Signed in as @${account.handle}`)
Toast.show(_(msg`Signed in as @${account.handle}`))
}, 100)
}
} else {
onSelectAccount(account)
}
},
[currentAccount, track, initSession, onSelectAccount, setShowLoggedOut],
[currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
)
return (
+10 -6
View File
@@ -67,7 +67,7 @@ export const ForgotPasswordForm = ({
const onPressNext = async () => {
if (!EmailValidator.validate(email)) {
return setError('Your email appears to be invalid.')
return setError(_(msg`Your email appears to be invalid.`))
}
setError('')
@@ -83,7 +83,9 @@ export const ForgotPasswordForm = ({
setIsProcessing(false)
if (isNetworkError(e)) {
setError(
'Unable to contact your service. Please check your Internet connection.',
_(
msg`Unable to contact your service. Please check your Internet connection.`,
),
)
} else {
setError(cleanError(errMsg))
@@ -112,7 +114,9 @@ export const ForgotPasswordForm = ({
onPress={onPressSelectService}
accessibilityRole="button"
accessibilityLabel={_(msg`Hosting provider`)}
accessibilityHint="Sets hosting provider for password reset">
accessibilityHint={_(
msg`Sets hosting provider for password reset`,
)}>
<FontAwesomeIcon
icon="globe"
style={[pal.textLight, styles.groupContentIcon]}
@@ -136,7 +140,7 @@ export const ForgotPasswordForm = ({
<TextInput
testID="forgotPasswordEmail"
style={[pal.text, styles.textInput]}
placeholder="Email address"
placeholder={_(msg`Email address`)}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoFocus
@@ -146,7 +150,7 @@ export const ForgotPasswordForm = ({
onChangeText={setEmail}
editable={!isProcessing}
accessibilityLabel={_(msg`Email`)}
accessibilityHint="Sets email for password reset"
accessibilityHint={_(msg`Sets email for password reset`)}
/>
</View>
</View>
@@ -179,7 +183,7 @@ export const ForgotPasswordForm = ({
onPress={onPressNext}
accessibilityRole="button"
accessibilityLabel={_(msg`Go to next`)}
accessibilityHint="Navigates to the next screen">
accessibilityHint={_(msg`Navigates to the next screen`)}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
+15 -8
View File
@@ -107,17 +107,21 @@ export const LoginForm = ({
})
} catch (e: any) {
const errMsg = e.toString()
logger.warn('Failed to login', {error: e})
setIsProcessing(false)
if (errMsg.includes('Authentication Required')) {
logger.info('Failed to login due to invalid credentials', {
error: errMsg,
})
setError(_(msg`Invalid username or password`))
} else if (isNetworkError(e)) {
logger.warn('Failed to login due to network error', {error: errMsg})
setError(
_(
msg`Unable to contact your service. Please check your Internet connection.`,
),
)
} else {
logger.warn('Failed to login', {error: errMsg})
setError(cleanError(errMsg))
}
}
@@ -141,7 +145,7 @@ export const LoginForm = ({
onPress={onPressSelectService}
accessibilityRole="button"
accessibilityLabel={_(msg`Select service`)}
accessibilityHint="Sets server for the Bluesky client">
accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
{toNiceDomain(serviceUrl)}
</Text>
@@ -174,6 +178,7 @@ export const LoginForm = ({
autoCorrect={false}
autoComplete="username"
returnKeyType="next"
textContentType="username"
onSubmitEditing={() => {
passwordInputRef.current?.focus()
}}
@@ -185,7 +190,9 @@ export const LoginForm = ({
}
editable={!isProcessing}
accessibilityLabel={_(msg`Username or email address`)}
accessibilityHint="Input the username or email address you used at signup"
accessibilityHint={_(
msg`Input the username or email address you used at signup`,
)}
/>
</View>
<View style={[pal.borderDark, styles.groupContent]}>
@@ -216,8 +223,8 @@ export const LoginForm = ({
accessibilityLabel={_(msg`Password`)}
accessibilityHint={
identifier === ''
? 'Input your password'
: `Input the password tied to ${identifier}`
? _(msg`Input your password`)
: _(msg`Input the password tied to ${identifier}`)
}
/>
<TouchableOpacity
@@ -226,7 +233,7 @@ export const LoginForm = ({
onPress={onPressForgotPassword}
accessibilityRole="button"
accessibilityLabel={_(msg`Forgot password`)}
accessibilityHint="Opens password reset form">
accessibilityHint={_(msg`Opens password reset form`)}>
<Text style={pal.link}>
<Trans>Forgot</Trans>
</Text>
@@ -256,7 +263,7 @@ export const LoginForm = ({
onPress={onPressRetryConnect}
accessibilityRole="button"
accessibilityLabel={_(msg`Retry`)}
accessibilityHint="Retries login">
accessibilityHint={_(msg`Retries login`)}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Retry</Trans>
</Text>
@@ -276,7 +283,7 @@ export const LoginForm = ({
onPress={onPressNext}
accessibilityRole="button"
accessibilityLabel={_(msg`Go to next`)}
accessibilityHint="Navigates to the next screen">
accessibilityHint={_(msg`Navigates to the next screen`)}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
@@ -36,7 +36,7 @@ export const PasswordUpdatedForm = ({
onPress={onPressNext}
accessibilityRole="button"
accessibilityLabel={_(msg`Close alert`)}
accessibilityHint="Closes password update alert">
accessibilityHint={_(msg`Closes password update alert`)}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Okay</Trans>
</Text>
@@ -95,7 +95,7 @@ export const SetNewPasswordForm = ({
<TextInput
testID="resetCodeInput"
style={[pal.text, styles.textInput]}
placeholder="Reset code"
placeholder={_(msg`Reset code`)}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
@@ -106,7 +106,9 @@ export const SetNewPasswordForm = ({
editable={!isProcessing}
accessible={true}
accessibilityLabel={_(msg`Reset code`)}
accessibilityHint="Input code sent to your email for password reset"
accessibilityHint={_(
msg`Input code sent to your email for password reset`,
)}
/>
</View>
<View style={[pal.borderDark, styles.groupContent]}>
@@ -117,7 +119,7 @@ export const SetNewPasswordForm = ({
<TextInput
testID="newPasswordInput"
style={[pal.text, styles.textInput]}
placeholder="New password"
placeholder={_(msg`New password`)}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
@@ -128,7 +130,7 @@ export const SetNewPasswordForm = ({
editable={!isProcessing}
accessible={true}
accessibilityLabel={_(msg`Password`)}
accessibilityHint="Input new password"
accessibilityHint={_(msg`Input new password`)}
/>
</View>
</View>
@@ -161,7 +163,7 @@ export const SetNewPasswordForm = ({
onPress={onPressNext}
accessibilityRole="button"
accessibilityLabel={_(msg`Go to next`)}
accessibilityHint="Navigates to the next screen">
accessibilityHint={_(msg`Navigates to the next screen`)}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
@@ -18,6 +18,8 @@ import {
} from '#/state/queries/preferences'
import {logger} from '#/logger'
import {useAnalytics} from '#/lib/analytics/analytics'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function RecommendedFeedsItem({
item,
@@ -26,6 +28,7 @@ export function RecommendedFeedsItem({
}) {
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {
mutateAsync: pinFeed,
@@ -51,7 +54,7 @@ export function RecommendedFeedsItem({
await removeFeed({uri: item.uri})
resetRemoveFeed()
} catch (e) {
Toast.show('There was an issue contacting your server')
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {error: e})
}
} else {
@@ -60,7 +63,7 @@ export function RecommendedFeedsItem({
resetPinFeed()
track('Onboarding:CustomFeedAdded')
} catch (e) {
Toast.show('There was an issue contacting your server')
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to pin feed', {error: e})
}
}
@@ -94,7 +97,7 @@ export function RecommendedFeedsItem({
</Text>
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
by {sanitizeHandle(item.creator.handle, '@')}
<Trans>by {sanitizeHandle(item.creator.handle, '@')}</Trans>
</Text>
{item.description ? (
@@ -133,7 +136,7 @@ export function RecommendedFeedsItem({
color={pal.colors.textInverted}
/>
<Text type="lg-medium" style={pal.textInverted}>
Added
<Trans>Added</Trans>
</Text>
</>
) : (
@@ -144,7 +147,7 @@ export function RecommendedFeedsItem({
color={pal.colors.textInverted}
/>
<Text type="lg-medium" style={pal.textInverted}>
Add
<Trans>Add</Trans>
</Text>
</>
)}
@@ -83,7 +83,7 @@ export function RecommendedFollows({next}: Props) {
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
<Trans>Done</Trans>
<Trans context="action">Done</Trans>
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
@@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
import {Button} from 'view/com/util/forms/Button'
import {Trans} from '@lingui/macro'
type Props = {
next: () => void
@@ -17,7 +18,7 @@ export function WelcomeDesktop({next}: Props) {
const pal = usePalette('default')
const horizontal = useMediaQuery({minWidth: 1300})
const title = (
<>
<Trans>
<Text
style={[
pal.textLight,
@@ -40,7 +41,7 @@ export function WelcomeDesktop({next}: Props) {
]}>
Bluesky
</Text>
</>
</Trans>
)
return (
<TitleColumnLayout
@@ -52,10 +53,12 @@ export function WelcomeDesktop({next}: Props) {
<FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="xl-bold" style={[pal.text]}>
Bluesky is public.
<Trans>Bluesky is public.</Trans>
</Text>
<Text type="xl" style={[pal.text, s.pt2]}>
Your posts, likes, and blocks are public. Mutes are private.
<Trans>
Your posts, likes, and blocks are public. Mutes are private.
</Trans>
</Text>
</View>
</View>
@@ -63,10 +66,10 @@ export function WelcomeDesktop({next}: Props) {
<FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="xl-bold" style={[pal.text]}>
Bluesky is open.
<Trans>Bluesky is open.</Trans>
</Text>
<Text type="xl" style={[pal.text, s.pt2]}>
Never lose access to your followers and data.
<Trans>Never lose access to your followers and data.</Trans>
</Text>
</View>
</View>
@@ -74,10 +77,13 @@ export function WelcomeDesktop({next}: Props) {
<FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="xl-bold" style={[pal.text]}>
Bluesky is flexible.
<Trans>Bluesky is flexible.</Trans>
</Text>
<Text type="xl" style={[pal.text, s.pt2]}>
Choose the algorithms that power your experience with custom feeds.
<Trans>
Choose the algorithms that power your experience with custom
feeds.
</Trans>
</Text>
</View>
</View>
@@ -94,7 +100,7 @@ export function WelcomeDesktop({next}: Props) {
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
Next
<Trans context="action">Next</Trans>
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
+51 -41
View File
@@ -6,6 +6,7 @@ import {
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
TouchableOpacity,
@@ -28,8 +29,6 @@ import {UserAvatar} from '../util/UserAvatar'
import * as apilib from 'lib/api/index'
import {ComposerOpts} from 'state/shell/composer'
import {s, colors, gradients} from 'lib/styles'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {cleanError} from 'lib/strings/errors'
import {shortenLinks} from 'lib/strings/rich-text-manip'
import {toShortUrl} from 'lib/strings/url-helpers'
@@ -46,7 +45,6 @@ import {Gallery} from './photos/Gallery'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {LabelsBtn} from './labels/LabelsBtn'
import {SelectLangBtn} from './select-language/SelectLangBtn'
import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
import {insertMentionAt} from 'lib/strings/mention-manip'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -63,6 +61,7 @@ import {useComposerControls} from '#/state/shell/composer'
import {emitPostCreated} from '#/state/events'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {logger} from '#/logger'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
type Props = ComposerOpts
export const ComposePost = observer(function ComposePost({
@@ -70,6 +69,7 @@ export const ComposePost = observer(function ComposePost({
onPost,
quote: initQuote,
mention: initMention,
openPicker,
}: Props) {
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -207,7 +207,11 @@ export const ComposePost = observer(function ComposePost({
setError('')
if (richtext.text.trim().length === 0 && gallery.isEmpty && !extLink) {
setError('Did you want to say anything?')
setError(_(msg`Did you want to say anything?`))
return
}
if (extLink?.isLoading) {
setError(_(msg`Please wait for your link card to finish loading`))
return
}
@@ -256,7 +260,11 @@ export const ComposePost = observer(function ComposePost({
setLangPrefs.savePostLanguageToHistory()
onPost?.()
onClose()
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
Toast.show(
replyTo
? _(msg`Your reply has been published`)
: _(msg`Your post has been published`),
)
}
const canPost = useMemo(
@@ -265,11 +273,17 @@ export const ComposePost = observer(function ComposePost({
(!requireAltTextEnabled || !gallery.needsAltText),
[graphemeLength, requireAltTextEnabled, gallery.needsAltText],
)
const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?`
const selectTextInputPlaceholder = replyTo
? _(msg`Write your reply`)
: _(msg`What's up?`)
const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
const hasMedia = gallery.size > 0 || Boolean(extLink)
const onEmojiButtonPress = useCallback(() => {
openPicker?.(textInput.current?.getCursorPosition())
}, [openPicker])
return (
<KeyboardAvoidingView
testID="composePostView"
@@ -283,7 +297,9 @@ export const ComposePost = observer(function ComposePost({
onAccessibilityEscape={onPressCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel`)}
accessibilityHint="Closes post composer and discards post draft">
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}>
<Text style={[pal.link, s.f18]}>
<Trans>Cancel</Trans>
</Text>
@@ -315,7 +331,7 @@ export const ComposePost = observer(function ComposePost({
onPress={onPressPublish}
accessibilityRole="button"
accessibilityLabel={
replyTo ? 'Publish reply' : 'Publish post'
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
accessibilityHint="">
<LinearGradient
@@ -327,14 +343,18 @@ export const ComposePost = observer(function ComposePost({
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? 'Reply' : 'Post'}
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans>Post</Trans>
<Trans context="action">Post</Trans>
</Text>
</View>
)}
@@ -370,22 +390,7 @@ export const ComposePost = observer(function ComposePost({
<ScrollView
style={styles.scrollView}
keyboardShouldPersistTaps="always">
{replyTo ? (
<View style={[pal.border, styles.replyToLayout]}>
<UserAvatar avatar={replyTo.author.avatar} size={50} />
<View style={styles.replyToPost}>
<Text type="xl-medium" style={[pal.text]}>
{sanitizeDisplayName(
replyTo.author.displayName ||
sanitizeHandle(replyTo.author.handle),
)}
</Text>
<Text type="post-text" style={pal.text} numberOfLines={6}>
{replyTo.text}
</Text>
</View>
</View>
) : undefined}
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
<View
style={[
@@ -407,7 +412,9 @@ export const ComposePost = observer(function ComposePost({
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`}
accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
)}
/>
</View>
@@ -436,9 +443,11 @@ export const ComposePost = observer(function ComposePost({
onPress={() => onPressAddLinkCard(url)}
accessibilityRole="button"
accessibilityLabel={_(msg`Add link card`)}
accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
accessibilityHint={_(
msg`Creates a card with a thumbnail. The card links to ${url}`,
)}>
<Text style={pal.text}>
<Trans>Add link card:</Trans>
<Trans>Add link card:</Trans>{' '}
<Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text>
</Text>
</TouchableOpacity>
@@ -452,7 +461,19 @@ export const ComposePost = observer(function ComposePost({
<OpenCameraBtn gallery={gallery} />
</>
) : null}
{!isMobile ? <EmojiPickerButton /> : null}
{!isMobile ? (
<Pressable
onPress={onEmojiButtonPress}
accessibilityRole="button"
accessibilityLabel={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}>
<FontAwesomeIcon
icon={['far', 'face-smile']}
color={pal.colors.link}
size={22}
/>
</Pressable>
) : null}
<View style={s.flex1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
@@ -528,17 +549,6 @@ const styles = StyleSheet.create({
textInputLayoutMobile: {
flex: 1,
},
replyToLayout: {
flexDirection: 'row',
borderTopWidth: 1,
paddingTop: 16,
paddingBottom: 16,
},
replyToPost: {
flex: 1,
paddingLeft: 13,
paddingRight: 8,
},
addExtLinkBtn: {
borderWidth: 1,
borderRadius: 24,
+254
View File
@@ -0,0 +1,254 @@
import React from 'react'
import {LayoutAnimation, Pressable, StyleSheet, View} from 'react-native'
import {Image} from 'expo-image'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedPost,
} from '@atproto/api'
import {ComposerOptsPostRef} from 'state/shell/composer'
import {usePalette} from 'lib/hooks/usePalette'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {Text} from 'view/com/util/text/Text'
import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed'
export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
const pal = usePalette('default')
const {_} = useLingui()
const {embed} = replyTo
const [showFull, setShowFull] = React.useState(false)
const onPress = React.useCallback(() => {
setShowFull(prev => !prev)
LayoutAnimation.configureNext({
duration: 350,
update: {type: 'spring', springDamping: 0.7},
})
}, [])
const quote = React.useMemo(() => {
if (
AppBskyEmbedRecord.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record) &&
AppBskyFeedPost.isRecord(embed.record.value)
) {
// Not going to include the images right now
return {
author: embed.record.author,
cid: embed.record.cid,
uri: embed.record.uri,
indexedAt: embed.record.indexedAt,
text: embed.record.value.text,
}
} else if (
AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
AppBskyFeedPost.isRecord(embed.record.record.value)
) {
return {
author: embed.record.record.author,
cid: embed.record.record.cid,
uri: embed.record.record.uri,
indexedAt: embed.record.record.indexedAt,
text: embed.record.record.value.text,
}
}
}, [embed])
const images = React.useMemo(() => {
if (AppBskyEmbedImages.isView(embed)) {
return embed.images
} else if (
AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedImages.isView(embed.media)
) {
return embed.media.images
}
}, [embed])
return (
<Pressable
style={[pal.border, styles.replyToLayout]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={_(
msg`Expand or collapse the full post you are replying to`,
)}
accessibilityHint={_(
msg`Expand or collapse the full post you are replying to`,
)}>
<UserAvatar avatar={replyTo.author.avatar} size={50} />
<View style={styles.replyToPost}>
<Text type="xl-medium" style={[pal.text]}>
{sanitizeDisplayName(
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
)}
</Text>
<View style={styles.replyToBody}>
<View style={styles.replyToText}>
<Text
type="post-text"
style={pal.text}
numberOfLines={!showFull ? 6 : undefined}>
{replyTo.text}
</Text>
</View>
{images && (
<ComposerReplyToImages images={images} showFull={showFull} />
)}
</View>
{showFull && quote && <QuoteEmbed quote={quote} />}
</View>
</Pressable>
)
}
function ComposerReplyToImages({
images,
}: {
images: AppBskyEmbedImages.ViewImage[]
showFull: boolean
}) {
return (
<View
style={{
width: 65,
flexDirection: 'column',
alignItems: 'center',
}}>
<View style={styles.imagesContainer}>
{(images.length === 1 && (
<Image
source={{uri: images[0].thumb}}
style={styles.singleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
)) ||
(images.length === 2 && (
<View style={[styles.imagesInner, styles.imagesRow]}>
<Image
source={{uri: images[0].thumb}}
style={styles.doubleImageTall}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
<Image
source={{uri: images[1].thumb}}
style={styles.doubleImageTall}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
</View>
)) ||
(images.length === 3 && (
<View style={[styles.imagesInner, styles.imagesRow]}>
<Image
source={{uri: images[0].thumb}}
style={styles.doubleImageTall}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
<View style={styles.imagesInner}>
<Image
source={{uri: images[1].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
<Image
source={{uri: images[2].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
</View>
</View>
)) ||
(images.length === 4 && (
<View style={styles.imagesInner}>
<View style={[styles.imagesInner, styles.imagesRow]}>
<Image
source={{uri: images[0].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
<Image
source={{uri: images[1].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
</View>
<View style={[styles.imagesInner, styles.imagesRow]}>
<Image
source={{uri: images[2].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
<Image
source={{uri: images[3].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
</View>
</View>
))}
</View>
</View>
)
}
const styles = StyleSheet.create({
replyToLayout: {
flexDirection: 'row',
borderTopWidth: 1,
paddingTop: 16,
paddingBottom: 16,
},
replyToPost: {
flex: 1,
paddingLeft: 13,
paddingRight: 8,
},
replyToBody: {
flexDirection: 'row',
gap: 10,
},
replyToText: {
flex: 1,
flexGrow: 1,
},
imagesContainer: {
borderRadius: 6,
overflow: 'hidden',
marginTop: 2,
},
imagesInner: {
gap: 2,
},
imagesRow: {
flexDirection: 'row',
},
singleImage: {
width: 65,
height: 65,
},
doubleImageTall: {
width: 32.5,
height: 65,
},
doubleImage: {
width: 32.5,
height: 32.5,
},
})
+1 -1
View File
@@ -68,7 +68,7 @@ export const ExternalEmbed = ({
onPress={onRemove}
accessibilityRole="button"
accessibilityLabel={_(msg`Remove image preview`)}
accessibilityHint={`Removes default thumbnail from ${link.uri}`}
accessibilityHint={_(msg`Removes default thumbnail from ${link.uri}`)}
onAccessibilityEscape={onRemove}>
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</TouchableOpacity>
+1 -1
View File
@@ -22,7 +22,7 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
onPress={() => onPressCompose()}
accessibilityRole="button"
accessibilityLabel={_(msg`Compose reply`)}
accessibilityHint="Opens composer">
accessibilityHint={_(msg`Opens composer`)}>
<UserAvatar avatar={profile?.avatar} size={38} />
<Text
type="xl"
@@ -58,7 +58,7 @@ export function OpenCameraBtn({gallery}: Props) {
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Camera`)}
accessibilityHint="Opens camera on device">
accessibilityHint={_(msg`Opens camera on device`)}>
<FontAwesomeIcon
icon="camera"
style={pal.link as FontAwesomeIconStyle}
@@ -41,7 +41,7 @@ export function SelectPhotoBtn({gallery}: Props) {
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Gallery`)}
accessibilityHint="Opens device photo gallery">
accessibilityHint={_(msg`Opens device photo gallery`)}>
<FontAwesomeIcon
icon={['far', 'image']}
style={pal.link as FontAwesomeIconStyle}
@@ -32,6 +32,7 @@ import {POST_IMG_MAX} from 'lib/constants'
export interface TextInputRef {
focus: () => void
blur: () => void
getCursorPosition: () => DOMRect | undefined
}
interface TextInputProps extends ComponentProps<typeof RNTextInput> {
@@ -74,6 +75,7 @@ export const TextInput = forwardRef(function TextInputImpl(
blur: () => {
textInput.current?.blur()
},
getCursorPosition: () => undefined, // Not implemented on native
}))
const onChangeText = useCallback(
@@ -22,6 +22,7 @@ import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
export interface TextInputRef {
focus: () => void
blur: () => void
getCursorPosition: () => DOMRect | undefined
}
interface TextInputProps {
@@ -169,6 +170,10 @@ export const TextInput = React.forwardRef(function TextInputImpl(
React.useImperativeHandle(ref, () => ({
focus: () => {}, // TODO
blur: () => {}, // TODO
getCursorPosition: () => {
const pos = editor?.state.selection.$anchor.pos
return pos ? editor?.view.coordsAtPos(pos) : undefined
},
}))
return (
@@ -17,6 +17,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {useGrapheme} from '../hooks/useGrapheme'
import {Trans} from '@lingui/macro'
interface MentionListRef {
onKeyDown: (props: SuggestionKeyDownProps) => boolean
@@ -187,7 +188,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
})
) : (
<Text type="sm" style={[pal.text, styles.noResult]}>
No result
<Trans>No result</Trans>
</Text>
)}
</View>
@@ -1,11 +1,17 @@
import React from 'react'
import Picker from '@emoji-mart/react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {
StyleSheet,
TouchableWithoutFeedback,
useWindowDimensions,
View,
} from 'react-native'
import {textInputWebEmitter} from '../TextInput.web'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {useMediaQuery} from 'react-responsive'
const HEIGHT_OFFSET = 40
const WIDTH_OFFSET = 100
const PICKER_HEIGHT = 435 + HEIGHT_OFFSET
const PICKER_WIDTH = 350 + WIDTH_OFFSET
export type Emoji = {
aliases?: string[]
@@ -18,59 +24,87 @@ export type Emoji = {
unified: string
}
export function EmojiPickerButton() {
const pal = usePalette('default')
const [open, setOpen] = React.useState(false)
const onOpenChange = (o: boolean) => {
setOpen(o)
}
const close = () => {
setOpen(false)
}
return (
<DropdownMenu.Root open={open} onOpenChange={onOpenChange}>
<DropdownMenu.Trigger style={styles.trigger as React.CSSProperties}>
<FontAwesomeIcon
icon={['far', 'face-smile']}
color={pal.colors.link}
size={22}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<EmojiPicker close={close} />
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
export interface EmojiPickerState {
isOpen: boolean
pos: {top: number; left: number; right: number; bottom: number}
}
export function EmojiPicker({close}: {close: () => void}) {
interface IProps {
state: EmojiPickerState
close: () => void
}
export function EmojiPicker({state, close}: IProps) {
const {height, width} = useWindowDimensions()
const isShiftDown = React.useRef(false)
const position = React.useMemo(() => {
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
const fitsAbove = PICKER_HEIGHT < state.pos.top
const placeOnLeft = PICKER_WIDTH < state.pos.left
const screenYMiddle = height / 2 - PICKER_HEIGHT / 2
if (fitsBelow) {
return {
top: state.pos.top + HEIGHT_OFFSET,
}
} else if (fitsAbove) {
return {
bottom: height - state.pos.bottom + HEIGHT_OFFSET,
}
} else {
return {
top: screenYMiddle,
left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined,
right: !placeOnLeft
? width - state.pos.right - PICKER_WIDTH
: undefined,
}
}
}, [state.pos, height, width])
React.useEffect(() => {
if (!state.isOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
isShiftDown.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
isShiftDown.current = false
}
}
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('keyup', onKeyUp, true)
return () => {
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('keyup', onKeyUp, true)
}
}, [state.isOpen])
const onInsert = (emoji: Emoji) => {
textInputWebEmitter.emit('emoji-inserted', emoji)
close()
if (!isShiftDown.current) {
close()
}
}
const reducedPadding = useMediaQuery({query: '(max-height: 750px)'})
const noPadding = useMediaQuery({query: '(max-height: 550px)'})
const noPicker = useMediaQuery({query: '(max-height: 350px)'})
if (!state.isOpen) return null
return (
// eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
<TouchableWithoutFeedback onPress={close} accessibilityViewIsModal>
<TouchableWithoutFeedback
accessibilityRole="button"
onPress={close}
accessibilityViewIsModal>
<View style={styles.mask}>
{/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
<TouchableWithoutFeedback
onPress={e => {
e.stopPropagation() // prevent event from bubbling up to the mask
}}>
<View
style={[
styles.picker,
{
paddingTop: noPadding ? 0 : reducedPadding ? 150 : 325,
display: noPicker ? 'none' : 'flex',
},
]}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[{position: 'absolute'}, position]}>
<Picker
data={async () => {
return (await import('./EmojiPickerData.json')).default
@@ -94,15 +128,7 @@ const styles = StyleSheet.create({
right: 0,
width: '100%',
height: '100%',
},
trigger: {
backgroundColor: 'transparent',
// @ts-ignore web only
border: 'none',
paddingTop: 4,
paddingLeft: 12,
paddingRight: 12,
cursor: 'pointer',
alignItems: 'center',
},
picker: {
marginHorizontal: 'auto',
+2 -1
View File
@@ -174,6 +174,7 @@ export function FeedPage({
feed={feed}
feedParams={feedParams}
pollInterval={POLL_FREQ}
disablePoll={hasNew}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
onHasNew={setHasNew}
@@ -197,7 +198,7 @@ export function FeedPage({
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityLabel={_(msg({message: `New post`, context: 'action'}))}
accessibilityHint=""
/>
)}
+21 -14
View File
@@ -14,7 +14,7 @@ import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {msg} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
usePinFeedMutation,
@@ -108,9 +108,9 @@ export function FeedSourceCardLoaded({
try {
await removeFeed({uri: feed.uri})
// await item.unsave()
Toast.show('Removed from my feeds')
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show('There was an issue contacting your server')
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {error: e})
}
},
@@ -122,9 +122,9 @@ export function FeedSourceCardLoaded({
} else {
await saveFeed({uri: feed.uri})
}
Toast.show('Added to my feeds')
Toast.show(_(msg`Added to my feeds`))
} catch (e) {
Toast.show('There was an issue contacting your server')
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to save feed', {error: e})
}
}
@@ -164,7 +164,7 @@ export function FeedSourceCardLoaded({
testID={`feed-${feedUri}-toggleSave`}
disabled={isRemovePending}
accessibilityRole="button"
accessibilityLabel={'Remove from my feeds'}
accessibilityLabel={_(msg`Remove from my feeds`)}
accessibilityHint=""
onPress={() => {
openModal({
@@ -175,9 +175,11 @@ export function FeedSourceCardLoaded({
try {
await removeFeed({uri: feedUri})
// await item.unsave()
Toast.show('Removed from my feeds')
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show('There was an issue contacting your server')
Toast.show(
_(msg`There was an issue contacting your server`),
)
logger.error('Failed to unsave feed', {error: e})
}
},
@@ -223,19 +225,22 @@ export function FeedSourceCardLoaded({
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
{feed.type === 'feed' ? 'Feed' : 'List'} by{' '}
{sanitizeHandle(feed.creatorHandle, '@')}
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
<Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
)}
</Text>
</View>
{showSaveBtn && feed.type === 'feed' && (
<View>
<View style={[s.justifyCenter]}>
<Pressable
testID={`feed-${feed.displayName}-toggleSave`}
disabled={isSavePending || isPinPending || isRemovePending}
accessibilityRole="button"
accessibilityLabel={
isSaved ? 'Remove from my feeds' : 'Add to my feeds'
isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`)
}
accessibilityHint=""
onPress={onToggleSaved}
@@ -269,8 +274,10 @@ export function FeedSourceCardLoaded({
{showLikes && feed.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
<Trans>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
</Trans>
</Text>
) : null}
</Pressable>
+7 -3
View File
@@ -9,13 +9,14 @@ import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {cleanError} from '#/lib/strings/errors'
import {useTheme} from '#/lib/ThemeContext'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {hydrateFeedGenerator} from '#/state/queries/feed'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {isNative} from '#/platform/detection'
import {useLingui} from '@lingui/react'
const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'}
@@ -43,6 +44,7 @@ export const ProfileFeedgens = React.forwardRef<
ref,
) {
const pal = usePalette('default')
const {_} = useLingui()
const theme = useTheme()
const [isPTRing, setIsPTRing] = React.useState(false)
const opts = React.useMemo(() => ({enabled}), [enabled])
@@ -142,7 +144,9 @@ export const ProfileFeedgens = React.forwardRef<
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching your lists. Tap here to try again."
label={_(
msg`There was an issue fetching your lists. Tap here to try again.`,
)}
onPress={onPressRetryLoadMore}
/>
)
@@ -162,7 +166,7 @@ export const ProfileFeedgens = React.forwardRef<
}
return null
},
[error, refetch, onPressRetryLoadMore, pal, preferences],
[error, refetch, onPressRetryLoadMore, pal, preferences, _],
)
return (
@@ -24,7 +24,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => (
hitSlop={HIT_SLOP}
accessibilityRole="button"
accessibilityLabel={t`Close image`}
accessibilityHint="Closes viewer for header image"
accessibilityHint={t`Closes viewer for header image`}
onAccessibilityEscape={onRequestClose}>
<Text style={styles.closeText}></Text>
</TouchableOpacity>
@@ -320,6 +320,7 @@ const ImageItem = ({
accessibilityLabel={imageSrc.alt}
accessibilityHint=""
onLoad={() => setIsLoaded(true)}
cachePolicy="memory"
/>
</GestureDetector>
</Animated.View>
+10 -5
View File
@@ -15,6 +15,8 @@ import {
ProfileImageLightbox,
ImagesLightbox,
} from '#/state/lightbox'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function Lightbox() {
const {activeLightbox} = useLightbox()
@@ -53,6 +55,7 @@ export function Lightbox() {
}
function LightboxFooter({imageIndex}: {imageIndex: number}) {
const {_} = useLingui()
const {activeLightbox} = useLightbox()
const [isAltExpanded, setAltExpanded] = React.useState(false)
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions()
@@ -60,12 +63,14 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
const saveImageToAlbumWithToasts = React.useCallback(
async (uri: string) => {
if (!permissionResponse || permissionResponse.granted === false) {
Toast.show('Permission to access camera roll is required.')
Toast.show(_(msg`Permission to access camera roll is required.`))
if (permissionResponse?.canAskAgain) {
requestPermission()
} else {
Toast.show(
'Permission to access camera roll was denied. Please enable it in your system settings.',
_(
msg`Permission to access camera roll was denied. Please enable it in your system settings.`,
),
)
}
return
@@ -78,7 +83,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
Toast.show(`Failed to save image: ${String(e)}`)
}
},
[permissionResponse, requestPermission],
[permissionResponse, requestPermission, _],
)
const lightbox = activeLightbox
@@ -117,7 +122,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
onPress={() => saveImageToAlbumWithToasts(uri)}>
<FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} />
<Text type="xl" style={s.white}>
Save
<Trans context="action">Save</Trans>
</Text>
</Button>
<Button
@@ -126,7 +131,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
onPress={() => shareImageModal({uri})}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
Share
<Trans context="action">Share</Trans>
</Text>
</Button>
</View>
+4 -2
View File
@@ -117,7 +117,7 @@ function LightboxInner({
onPress={onClose}
accessibilityRole="button"
accessibilityLabel={_(msg`Close image viewer`)}
accessibilityHint="Exits image view"
accessibilityHint={_(msg`Exits image view`)}
onAccessibilityEscape={onClose}>
<View style={styles.imageCenterer}>
<Image
@@ -161,7 +161,9 @@ function LightboxInner({
<View style={styles.footer}>
<Pressable
accessibilityLabel={_(msg`Expand alt text`)}
accessibilityHint="If alt text is long, toggles alt text expanded state"
accessibilityHint={_(
msg`If alt text is long, toggles alt text expanded state`,
)}
onPress={() => {
setAltExpanded(!isAltExpanded)
}}>
+17 -7
View File
@@ -11,6 +11,7 @@ import {useSession} from '#/state/session'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {Trans} from '@lingui/macro'
export const ListCard = ({
testID,
@@ -76,19 +77,28 @@ export const ListCard = ({
{sanitizeDisplayName(list.name)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '}
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>User list by you</Trans>
) : (
<Trans>
User list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
{list.purpose === 'app.bsky.graph.defs#modlist' &&
'Moderation list '}
by{' '}
{list.creator.did === currentAccount?.did
? 'you'
: sanitizeHandle(list.creator.handle, '@')}
(list.creator.did === currentAccount?.did ? (
<Trans>Moderation list by you</Trans>
) : (
<Trans>
Moderation list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
</Text>
{!!list.viewer?.muted && (
<View style={s.flexRow}>
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
Subscribed
<Trans>Subscribed</Trans>
</Text>
</View>
</View>
+10 -3
View File
@@ -20,6 +20,8 @@ import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session'
import {cleanError} from '#/lib/strings/errors'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
const LOADING_ITEM = {_reactKey: '__loading__'}
const EMPTY_ITEM = {_reactKey: '__empty__'}
@@ -50,6 +52,7 @@ export function ListMembers({
desktopFixedHeightOffset?: number
}) {
const {track} = useAnalytics()
const {_} = useLingui()
const [isRefreshing, setIsRefreshing] = React.useState(false)
const {isMobile} = useWebMediaQueries()
const {openModal} = useModalControls()
@@ -143,12 +146,12 @@ export function ListMembers({
<Button
testID={`user-${profile.handle}-editBtn`}
type="default"
label="Edit"
label={_(msg({message: 'Edit', context: 'action'}))}
onPress={() => onPressEditMembership(profile)}
/>
)
},
[isOwner, onPressEditMembership],
[isOwner, onPressEditMembership, _],
)
const renderItem = React.useCallback(
@@ -165,7 +168,9 @@ export function ListMembers({
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching the list. Tap here to try again."
label={_(
msg`There was an issue fetching the list. Tap here to try again.`,
)}
onPress={onPressRetryLoadMore}
/>
)
@@ -180,6 +185,7 @@ export function ListMembers({
profile={(item as AppBskyGraphDefs.ListItemView).subject}
renderButton={renderMemberButton}
style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}}
noModFilter
/>
)
},
@@ -190,6 +196,7 @@ export function ListMembers({
onPressTryAgain,
onPressRetryLoadMore,
isMobile,
_,
],
)
+7 -3
View File
@@ -10,11 +10,12 @@ import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists'
import {logger} from '#/logger'
import {Trans} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {cleanError} from '#/lib/strings/errors'
import {useTheme} from '#/lib/ThemeContext'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {isNative} from '#/platform/detection'
import {useLingui} from '@lingui/react'
const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'}
@@ -42,6 +43,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
const pal = usePalette('default')
const theme = useTheme()
const {track} = useAnalytics()
const {_} = useLingui()
const [isPTRing, setIsPTRing] = React.useState(false)
const opts = React.useMemo(() => ({enabled}), [enabled])
const {
@@ -149,7 +151,9 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching your lists. Tap here to try again."
label={_(
msg`There was an issue fetching your lists. Tap here to try again.`,
)}
onPress={onPressRetryLoadMore}
/>
)
@@ -164,7 +168,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
/>
)
},
[error, refetch, onPressRetryLoadMore, pal],
[error, refetch, onPressRetryLoadMore, pal, _],
)
return (
+16 -12
View File
@@ -72,10 +72,10 @@ export function Component({}: {}) {
const onCopy = React.useCallback(() => {
if (appPassword) {
Clipboard.setString(appPassword)
Toast.show('Copied to clipboard')
Toast.show(_(msg`Copied to clipboard`))
setWasCopied(true)
}
}, [appPassword])
}, [appPassword, _])
const onDone = React.useCallback(() => {
closeModal()
@@ -85,7 +85,9 @@ export function Component({}: {}) {
// if name is all whitespace, we don't allow it
if (!name || !name.trim()) {
Toast.show(
'Please enter a name for your app password. All spaces is not allowed.',
_(
msg`Please enter a name for your app password. All spaces is not allowed.`,
),
'times',
)
return
@@ -93,14 +95,14 @@ export function Component({}: {}) {
// if name is too short (under 4 chars), we don't allow it
if (name.length < 4) {
Toast.show(
'App Password names must be at least 4 characters long.',
_(msg`App Password names must be at least 4 characters long.`),
'times',
)
return
}
if (passwords?.find(p => p.name === name)) {
Toast.show('This name is already in use', 'times')
Toast.show(_(msg`This name is already in use`), 'times')
return
}
@@ -109,11 +111,11 @@ export function Component({}: {}) {
if (newPassword) {
setAppPassword(newPassword.password)
} else {
Toast.show('Failed to create app password.', 'times')
Toast.show(_(msg`Failed to create app password.`), 'times')
// TODO: better error handling (?)
}
} catch (e) {
Toast.show('Failed to create app password.', 'times')
Toast.show(_(msg`Failed to create app password.`), 'times')
logger.error('Failed to create app password', {error: e})
}
}
@@ -127,7 +129,9 @@ export function Component({}: {}) {
setName(text)
} else {
Toast.show(
'App Password names can only contain letters, numbers, spaces, dashes, and underscores.',
_(
msg`App Password names can only contain letters, numbers, spaces, dashes, and underscores.`,
),
)
}
}
@@ -158,7 +162,7 @@ export function Component({}: {}) {
style={[styles.input, pal.text]}
onChangeText={_onChangeText}
value={name}
placeholder="Enter a name for this App Password"
placeholder={_(msg`Enter a name for this App Password`)}
placeholderTextColor={pal.colors.textLight}
autoCorrect={false}
autoComplete="off"
@@ -175,7 +179,7 @@ export function Component({}: {}) {
onEndEditing={createAppPassword}
accessible={true}
accessibilityLabel={_(msg`Name`)}
accessibilityHint="Input name for app password"
accessibilityHint={_(msg`Input name for app password`)}
/>
</View>
) : (
@@ -184,7 +188,7 @@ export function Component({}: {}) {
onPress={onCopy}
accessibilityRole="button"
accessibilityLabel={_(msg`Copy`)}
accessibilityHint="Copies app password">
accessibilityHint={_(msg`Copies app password`)}>
<Text type="2xl-bold" style={[pal.text]}>
{appPassword}
</Text>
@@ -221,7 +225,7 @@ export function Component({}: {}) {
<View style={styles.btnContainer}>
<Button
type="primary"
label={!appPassword ? 'Create App Password' : 'Done'}
label={!appPassword ? _(msg`Create App Password`) : _(msg`Done`)}
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={!appPassword ? createAppPassword : onDone}
+65 -82
View File
@@ -1,14 +1,12 @@
import React, {useMemo, useCallback, useState} from 'react'
import {
ImageStyle,
KeyboardAvoidingView,
ScrollView,
StyleSheet,
TextInput,
TouchableOpacity,
View,
useWindowDimensions,
} from 'react-native'
import {ScrollView, TextInput} from './util'
import {Image} from 'expo-image'
import {usePalette} from 'lib/hooks/usePalette'
import {gradients, s} from 'lib/styles'
@@ -17,13 +15,13 @@ import {MAX_ALT_TEXT} from 'lib/constants'
import {useTheme} from 'lib/ThemeContext'
import {Text} from '../util/text/Text'
import LinearGradient from 'react-native-linear-gradient'
import {isAndroid, isWeb} from 'platform/detection'
import {isWeb} from 'platform/detection'
import {ImageModel} from 'state/models/media/image'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import {useModalControls} from '#/state/modals'
export const snapPoints = ['fullscreen']
export const snapPoints = ['100%']
interface Props {
image: ImageModel
@@ -54,102 +52,86 @@ export function Component({image}: Props) {
}
}, [image, windim])
const onUpdate = useCallback(
(v: string) => {
v = enforceLen(v, MAX_ALT_TEXT)
setAltText(v)
image.setAltText(v)
},
[setAltText, image],
)
const onPressSave = useCallback(() => {
image.setAltText(altText)
closeModal()
}, [closeModal, image, altText])
const onPressCancel = () => {
closeModal()
}
return (
<KeyboardAvoidingView
behavior={isAndroid ? 'height' : 'padding'}
style={[pal.view, styles.container]}>
<ScrollView
testID="altTextImageModal"
style={styles.scrollContainer}
keyboardShouldPersistTaps="always"
nativeID="imageAltText">
<View style={styles.scrollInner}>
<View style={[pal.viewLight, styles.imageContainer]}>
<Image
testID="selectedPhotoImage"
style={imageStyles}
source={{
uri: image.cropped?.path ?? image.path,
}}
contentFit="contain"
accessible={true}
accessibilityIgnoresInvertColors
/>
</View>
<TextInput
testID="altTextImageInput"
style={[styles.textArea, pal.border, pal.text]}
keyboardAppearance={theme.colorScheme}
multiline
placeholder="Add alt text"
placeholderTextColor={pal.colors.textLight}
value={altText}
onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
accessibilityLabel={_(msg`Image alt text`)}
accessibilityHint=""
accessibilityLabelledBy="imageAltText"
autoFocus
<ScrollView
testID="altTextImageModal"
style={[pal.view, styles.scrollContainer]}
keyboardShouldPersistTaps="always"
nativeID="imageAltText">
<View style={styles.scrollInner}>
<View style={[pal.viewLight, styles.imageContainer]}>
<Image
testID="selectedPhotoImage"
style={imageStyles}
source={{
uri: image.cropped?.path ?? image.path,
}}
contentFit="contain"
accessible={true}
accessibilityIgnoresInvertColors
/>
<View style={styles.buttonControls}>
<TouchableOpacity
testID="altTextImageSaveBtn"
onPress={onPressSave}
accessibilityLabel={_(msg`Save alt text`)}
accessibilityHint={`Saves alt text, which reads: ${altText}`}
accessibilityRole="button">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.button]}>
<Text type="button-lg" style={[s.white, s.bold]}>
<Trans>Save</Trans>
</Text>
</LinearGradient>
</TouchableOpacity>
<TouchableOpacity
testID="altTextImageCancelBtn"
onPress={onPressCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel add image alt text`)}
accessibilityHint=""
onAccessibilityEscape={onPressCancel}>
<View style={[styles.button]}>
<Text type="button-lg" style={[pal.textLight]}>
<Trans>Cancel</Trans>
</Text>
</View>
</TouchableOpacity>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
<TextInput
testID="altTextImageInput"
style={[styles.textArea, pal.border, pal.text]}
keyboardAppearance={theme.colorScheme}
multiline
placeholder={_(msg`Add alt text`)}
placeholderTextColor={pal.colors.textLight}
value={altText}
onChangeText={onUpdate}
accessibilityLabel={_(msg`Image alt text`)}
accessibilityHint=""
accessibilityLabelledBy="imageAltText"
autoFocus
/>
<View style={styles.buttonControls}>
<TouchableOpacity
testID="altTextImageSaveBtn"
onPress={onPressSave}
accessibilityLabel={_(msg`Save alt text`)}
accessibilityHint=""
accessibilityRole="button">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.button]}>
<Text type="button-lg" style={[s.white, s.bold]}>
<Trans>Done</Trans>
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
height: '100%',
width: '100%',
paddingVertical: isWeb ? 0 : 18,
},
scrollContainer: {
flex: 1,
height: '100%',
paddingHorizontal: isWeb ? 0 : 12,
paddingVertical: isWeb ? 0 : 24,
},
scrollInner: {
gap: 12,
paddingTop: isWeb ? 0 : 12,
},
imageContainer: {
borderRadius: 8,
@@ -173,5 +155,6 @@ const styles = StyleSheet.create({
},
buttonControls: {
gap: 8,
paddingBottom: isWeb ? 0 : 50,
},
})
+2 -2
View File
@@ -38,14 +38,14 @@ export function Component(props: ReportComponentProps) {
? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef'
await getAgent().createModerationReport({
reasonType: ComAtprotoModerationDefs.REASONOTHER,
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
subject: {
$type,
...props,
},
reason: details,
})
Toast.show("We'll look into your appeal promptly.")
Toast.show(_(msg`We'll look into your appeal promptly.`))
} finally {
closeModal()
}
+3 -2
View File
@@ -23,7 +23,7 @@ import {
} from '#/state/queries/preferences'
import {logger} from '#/logger'
export const snapPoints = ['50%']
export const snapPoints = ['50%', '90%']
function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
const pal = usePalette('default')
@@ -63,6 +63,7 @@ function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
<View>
<DateInput
handleAsUTC
testID="birthdayInput"
value={date}
onChange={setDate}
@@ -70,7 +71,7 @@ function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
buttonStyle={[pal.border, styles.dateInputButton]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
accessibilityHint="Enter your birth date"
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
/>
</View>
+3 -3
View File
@@ -38,7 +38,7 @@ export function Component() {
const onRequestChange = async () => {
if (email === currentAccount?.email) {
setError('Enter your new email above')
setError(_(msg`Enter your new email above`))
return
}
setError('')
@@ -53,7 +53,7 @@ export function Component() {
email: email.trim(),
emailConfirmed: false,
})
Toast.show('Email updated')
Toast.show(_(msg`Email updated`))
setStage(Stages.Done)
}
} catch (e) {
@@ -85,7 +85,7 @@ export function Component() {
email: email.trim(),
emailConfirmed: false,
})
Toast.show('Email updated')
Toast.show(_(msg`Email updated`))
setStage(Stages.Done)
} catch (e) {
setError(cleanError(String(e)))
+9 -7
View File
@@ -147,7 +147,7 @@ export function Inner({
onPress={onPressCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel change handle`)}
accessibilityHint="Exits handle change process"
accessibilityHint={_(msg`Exits handle change process`)}
onAccessibilityEscape={onPressCancel}>
<Text type="lg" style={pal.textLight}>
Cancel
@@ -168,7 +168,7 @@ export function Inner({
onPress={onPressSave}
accessibilityRole="button"
accessibilityLabel={_(msg`Save handle change`)}
accessibilityHint={`Saves handle change to ${handle}`}>
accessibilityHint={_(msg`Saves handle change to ${handle}`)}>
<Text type="2xl-medium" style={pal.link}>
<Trans>Save</Trans>
</Text>
@@ -263,14 +263,16 @@ function ProvidedHandleForm({
editable={!isProcessing}
accessible={true}
accessibilityLabel={_(msg`Handle`)}
accessibilityHint="Sets Bluesky username"
accessibilityHint={_(msg`Sets Bluesky username`)}
/>
</View>
<Text type="md" style={[pal.textLight, s.pl10, s.pt10]}>
<Trans>Your full handle will be</Trans>{' '}
<Text type="md-bold" style={pal.textLight}>
@{createFullHandle(handle, userDomain)}
</Text>
<Trans>
Your full handle will be{' '}
<Text type="md-bold" style={pal.textLight}>
@{createFullHandle(handle, userDomain)}
</Text>
</Trans>
</Text>
<TouchableOpacity
onPress={onToggleCustom}
+5 -5
View File
@@ -12,7 +12,7 @@ import {cleanError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import type {ConfirmModal} from '#/state/modals'
import {useModalControls} from '#/state/modals'
@@ -72,10 +72,10 @@ export function Component({
onPress={onPress}
style={[styles.btn, confirmBtnStyle]}
accessibilityRole="button"
accessibilityLabel={_(msg`Confirm`)}
accessibilityLabel={_(msg({message: 'Confirm', context: 'action'}))}
accessibilityHint="">
<Text style={[s.white, s.bold, s.f18]}>
{confirmBtnText ?? 'Confirm'}
{confirmBtnText ?? <Trans context="action">Confirm</Trans>}
</Text>
</TouchableOpacity>
)}
@@ -85,10 +85,10 @@ export function Component({
onPress={onPressCancel}
style={[styles.btnCancel, s.mt10]}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel`)}
accessibilityLabel={_(msg({message: 'Cancel', context: 'action'}))}
accessibilityHint="">
<Text type="button-lg" style={pal.textLight}>
{cancelBtnText ?? 'Cancel'}
{cancelBtnText ?? <Trans context="action">Cancel</Trans>}
</Text>
</TouchableOpacity>
)}
@@ -104,6 +104,7 @@ export function Component({}: {}) {
function AdultContentEnabledPref() {
const pal = usePalette('default')
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {mutate, variables} = usePreferencesSetAdultContentMutation()
const {openModal} = useModalControls()
@@ -121,36 +122,44 @@ function AdultContentEnabledPref() {
enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
})
} catch (e) {
Toast.show('There was an issue syncing your preferences with the server')
Toast.show(
_(msg`There was an issue syncing your preferences with the server`),
)
logger.error('Failed to update preferences with server', {error: e})
}
}, [variables, preferences, mutate])
}, [variables, preferences, mutate, _])
return (
<View style={s.mb10}>
{isIOS ? (
preferences?.adultContentEnabled ? null : (
<Text type="md" style={pal.textLight}>
Adult content can only be enabled via the Web at{' '}
<TextLink
style={pal.link}
href="https://bsky.app"
text="bsky.app"
/>
.
<Trans>
Adult content can only be enabled via the Web at{' '}
<TextLink
style={pal.link}
href="https://bsky.app"
text="bsky.app"
/>
.
</Trans>
</Text>
)
) : typeof preferences?.birthDate === 'undefined' ? (
<View style={[pal.viewLight, styles.agePrompt]}>
<Text type="md" style={[pal.text, {flex: 1}]}>
Confirm your age to enable adult content.
<Trans>Confirm your age to enable adult content.</Trans>
</Text>
<Button type="primary" label="Set Age" onPress={onSetAge} />
<Button
type="primary"
label={_(msg({message: 'Set Age', context: 'action'}))}
onPress={onSetAge}
/>
</View>
) : (preferences.userAge || 0) >= 18 ? (
<ToggleButton
type="default-light"
label="Enable Adult Content"
label={_(msg`Enable Adult Content`)}
isSelected={variables?.enabled ?? preferences?.adultContentEnabled}
onPress={onToggleAdultContent}
style={styles.toggleBtn}
@@ -158,9 +167,13 @@ function AdultContentEnabledPref() {
) : (
<View style={[pal.viewLight, styles.agePrompt]}>
<Text type="md" style={[pal.text, {flex: 1}]}>
You must be 18 or older to enable adult content.
<Trans>You must be 18 or older to enable adult content.</Trans>
</Text>
<Button type="primary" label="Set Age" onPress={onSetAge} />
<Button
type="primary"
label={_(msg({message: 'Set Age', context: 'action'}))}
onPress={onSetAge}
/>
</View>
)}
</View>
@@ -203,7 +216,7 @@ function ContentLabelPref({
{disabled || !visibility ? (
<Text type="sm-bold" style={pal.textLight}>
Hide
<Trans context="action">Hide</Trans>
</Text>
) : (
<SelectGroup
@@ -223,12 +236,14 @@ interface SelectGroupProps {
}
function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) {
const {_} = useLingui()
return (
<View style={styles.selectableBtns}>
<SelectableBtn
current={current}
value="hide"
label="Hide"
label={_(msg`Hide`)}
left
onChange={onChange}
labelGroup={labelGroup}
@@ -236,14 +251,14 @@ function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) {
<SelectableBtn
current={current}
value="warn"
label="Warn"
label={_(msg`Warn`)}
onChange={onChange}
labelGroup={labelGroup}
/>
<SelectableBtn
current={current}
value="ignore"
label="Show"
label={_(msg`Show`)}
right
onChange={onChange}
labelGroup={labelGroup}
@@ -273,6 +288,8 @@ function SelectableBtn({
}: SelectableBtnProps) {
const pal = usePalette('default')
const palPrimary = usePalette('inverted')
const {_} = useLingui()
return (
<Pressable
style={[
@@ -285,7 +302,9 @@ function SelectableBtn({
onPress={() => onChange(value)}
accessibilityRole="button"
accessibilityLabel={value}
accessibilityHint={`Set ${value} for ${labelGroup} content moderation policy`}>
accessibilityHint={_(
msg`Set ${value} for ${labelGroup} content moderation policy`,
)}>
<Text style={current === value ? palPrimary.text : pal.text}>
{label}
</Text>
+33 -12
View File
@@ -65,7 +65,6 @@ export function Component({
return 'app.bsky.graph.defs#curatelist'
}, [list, purpose])
const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist'
const purposeLabel = isCurateList ? 'User' : 'Moderation'
const [isProcessing, setProcessing] = useState<boolean>(false)
const [name, setName] = useState<string>(list?.name || '')
@@ -106,7 +105,7 @@ export function Component({
}
const nameTrimmed = name.trim()
if (!nameTrimmed) {
setError('Name is required')
setError(_(msg`Name is required`))
return
}
setProcessing(true)
@@ -121,7 +120,11 @@ export function Component({
description: description.trim(),
avatar: newAvatar,
})
Toast.show(`${purposeLabel} list updated`)
Toast.show(
isCurateList
? _(msg`User list updated`)
: _(msg`Moderation list updated`),
)
onSave?.(list.uri)
} else {
const res = await listCreateMutation.mutateAsync({
@@ -130,14 +133,20 @@ export function Component({
description,
avatar: newAvatar,
})
Toast.show(`${purposeLabel} list created`)
Toast.show(
isCurateList
? _(msg`User list created`)
: _(msg`Moderation list created`),
)
onSave?.(res.uri)
}
closeModal()
} catch (e: any) {
if (isNetworkError(e)) {
setError(
'Failed to create the list. Check your internet connection and try again.',
_(
msg`Failed to create the list. Check your internet connection and try again.`,
),
)
} else {
setError(cleanError(e))
@@ -153,13 +162,13 @@ export function Component({
closeModal,
activePurpose,
isCurateList,
purposeLabel,
name,
description,
newAvatar,
list,
listMetadataMutation,
listCreateMutation,
_,
])
return (
@@ -174,7 +183,17 @@ export function Component({
testID="createOrEditListModal">
<Text style={[styles.title, pal.text]}>
<Trans>
{list ? 'Edit' : 'New'} {purposeLabel} List
{isCurateList ? (
list ? (
<Trans>Edit User List</Trans>
) : (
<Trans>New User List</Trans>
)
) : list ? (
<Trans>Edit Moderation List</Trans>
) : (
<Trans>New Moderation List</Trans>
)}
</Trans>
</Text>
{error !== '' && (
@@ -202,7 +221,9 @@ export function Component({
testID="editNameInput"
style={[styles.textInput, pal.border, pal.text]}
placeholder={
isCurateList ? 'e.g. Great Posters' : 'e.g. Spammers'
isCurateList
? _(msg`e.g. Great Posters`)
: _(msg`e.g. Spammers`)
}
placeholderTextColor={colors.gray4}
value={name}
@@ -222,8 +243,8 @@ export function Component({
style={[styles.textArea, pal.border, pal.text]}
placeholder={
isCurateList
? 'e.g. The posters who never miss.'
: 'e.g. Users that repeatedly reply with ads.'
? _(msg`e.g. The posters who never miss.`)
: _(msg`e.g. Users that repeatedly reply with ads.`)
}
placeholderTextColor={colors.gray4}
keyboardAppearance={theme.colorScheme}
@@ -254,7 +275,7 @@ export function Component({
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold]}>
<Trans>Save</Trans>
<Trans context="action">Save</Trans>
</Text>
</LinearGradient>
</TouchableOpacity>
@@ -269,7 +290,7 @@ export function Component({
onAccessibilityEscape={onPressCancel}>
<View style={[styles.btn]}>
<Text style={[s.black, s.bold, pal.text]}>
<Trans>Cancel</Trans>
<Trans context="action">Cancel</Trans>
</Text>
</View>
</TouchableOpacity>
+11 -7
View File
@@ -62,7 +62,7 @@ export function Component({}: {}) {
password,
token,
})
Toast.show('Your account has been deleted')
Toast.show(_(msg`Your account has been deleted`))
resetToTab('HomeTab')
removeAccount(currentAccount)
clearCurrentAccount()
@@ -125,7 +125,9 @@ export function Component({}: {}) {
onPress={onPressSendEmail}
accessibilityRole="button"
accessibilityLabel={_(msg`Send email`)}
accessibilityHint="Sends email with confirmation code for account deletion">
accessibilityHint={_(
msg`Sends email with confirmation code for account deletion`,
)}>
<LinearGradient
colors={[
gradients.blueLight.start,
@@ -135,7 +137,7 @@ export function Component({}: {}) {
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text type="button-lg" style={[s.white, s.bold]}>
<Trans>Send Email</Trans>
<Trans context="action">Send Email</Trans>
</Text>
</LinearGradient>
</TouchableOpacity>
@@ -147,7 +149,7 @@ export function Component({}: {}) {
accessibilityHint=""
onAccessibilityEscape={onCancel}>
<Text type="button-lg" style={pal.textLight}>
<Trans>Cancel</Trans>
<Trans context="action">Cancel</Trans>
</Text>
</TouchableOpacity>
</>
@@ -174,7 +176,9 @@ export function Component({}: {}) {
onChangeText={setConfirmCode}
accessibilityLabelledBy="confirmationCode"
accessibilityLabel={_(msg`Confirmation code`)}
accessibilityHint="Input confirmation code for account deletion"
accessibilityHint={_(
msg`Input confirmation code for account deletion`,
)}
/>
<Text type="lg" style={styles.description} nativeID="password">
<Trans>Please enter your password as well:</Trans>
@@ -189,7 +193,7 @@ export function Component({}: {}) {
onChangeText={setPassword}
accessibilityLabelledBy="password"
accessibilityLabel={_(msg`Password`)}
accessibilityHint="Input password for account deletion"
accessibilityHint={_(msg`Input password for account deletion`)}
/>
{error ? (
<View style={styles.mt20}>
@@ -220,7 +224,7 @@ export function Component({}: {}) {
accessibilityHint="Exits account deletion process"
onAccessibilityEscape={onCancel}>
<Text type="button-lg" style={pal.textLight}>
<Trans>Cancel</Trans>
<Trans context="action">Cancel</Trans>
</Text>
</TouchableOpacity>
</>
+5 -5
View File
@@ -112,16 +112,16 @@ export const Component = observer(function EditImageImpl({
// },
{
name: 'flip' as const,
label: 'Flip horizontal',
label: _(msg`Flip horizontal`),
onPress: onFlipHorizontal,
},
{
name: 'flip' as const,
label: 'Flip vertically',
label: _(msg`Flip vertically`),
onPress: onFlipVertical,
},
],
[onFlipHorizontal, onFlipVertical],
[onFlipHorizontal, onFlipVertical, _],
)
useEffect(() => {
@@ -284,7 +284,7 @@ export const Component = observer(function EditImageImpl({
size={label?.startsWith('Flip') ? 22 : 24}
style={[
pal.text,
label === 'Flip vertically'
label === _(msg`Flip vertically`)
? styles.flipVertical
: undefined,
]}
@@ -330,7 +330,7 @@ export const Component = observer(function EditImageImpl({
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text type="xl-medium" style={s.white}>
<Trans>Done</Trans>
<Trans context="action">Done</Trans>
</Text>
</LinearGradient>
</Pressable>
+7 -6
View File
@@ -125,7 +125,7 @@ export function Component({
newUserAvatar,
newUserBanner,
})
Toast.show('Profile updated')
Toast.show(_(msg`Profile updated`))
onUpdate?.()
closeModal()
} catch (e: any) {
@@ -142,6 +142,7 @@ export function Component({
newUserAvatar,
newUserBanner,
setImageError,
_,
])
return (
@@ -181,7 +182,7 @@ export function Component({
<TextInput
testID="editProfileDisplayNameInput"
style={[styles.textInput, pal.border, pal.text]}
placeholder="e.g. Alice Roberts"
placeholder={_(msg`e.g. Alice Roberts`)}
placeholderTextColor={colors.gray4}
value={displayName}
onChangeText={v =>
@@ -189,7 +190,7 @@ export function Component({
}
accessible={true}
accessibilityLabel={_(msg`Display name`)}
accessibilityHint="Edit your display name"
accessibilityHint={_(msg`Edit your display name`)}
/>
</View>
<View style={s.pb10}>
@@ -199,7 +200,7 @@ export function Component({
<TextInput
testID="editProfileDescriptionInput"
style={[styles.textArea, pal.border, pal.text]}
placeholder="e.g. Artist, dog-lover, and avid reader."
placeholder={_(msg`e.g. Artist, dog-lover, and avid reader.`)}
placeholderTextColor={colors.gray4}
keyboardAppearance={theme.colorScheme}
multiline
@@ -207,7 +208,7 @@ export function Component({
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
accessible={true}
accessibilityLabel={_(msg`Description`)}
accessibilityHint="Edit your profile description"
accessibilityHint={_(msg`Edit your profile description`)}
/>
</View>
{updateMutation.isPending ? (
@@ -221,7 +222,7 @@ export function Component({
onPress={onPressSave}
accessibilityRole="button"
accessibilityLabel={_(msg`Save`)}
accessibilityHint="Saves any changes to your profile">
accessibilityHint={_(msg`Saves any changes to your profile`)}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
+153
View File
@@ -0,0 +1,153 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {s, colors, gradients} from 'lib/styles'
import {Text} from '../util/text/Text'
import {ScrollView} from './util'
import {usePalette} from 'lib/hooks/usePalette'
import {
EmbedPlayerSource,
embedPlayerSources,
externalEmbedLabels,
} from '#/lib/strings/embed-player'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {useSetExternalEmbedPref} from '#/state/preferences/external-embeds-prefs'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
export const snapPoints = [450]
export function Component({
onAccept,
source,
}: {
onAccept: () => void
source: EmbedPlayerSource
}) {
const pal = usePalette('default')
const {closeModal} = useModalControls()
const {_} = useLingui()
const setExternalEmbedPref = useSetExternalEmbedPref()
const {isMobile} = useWebMediaQueries()
const onShowAllPress = React.useCallback(() => {
for (const key of embedPlayerSources) {
setExternalEmbedPref(key, 'show')
}
onAccept()
closeModal()
}, [closeModal, onAccept, setExternalEmbedPref])
const onShowPress = React.useCallback(() => {
setExternalEmbedPref(source, 'show')
onAccept()
closeModal()
}, [closeModal, onAccept, setExternalEmbedPref, source])
const onHidePress = React.useCallback(() => {
setExternalEmbedPref(source, 'hide')
closeModal()
}, [closeModal, setExternalEmbedPref, source])
return (
<ScrollView
testID="embedConsentModal"
style={[
s.flex1,
pal.view,
isMobile
? {paddingHorizontal: 20, paddingTop: 10}
: {paddingHorizontal: 30},
]}>
<Text style={[pal.text, styles.title]}>
<Trans>External Media</Trans>
</Text>
<Text style={pal.text}>
<Trans>
This content is hosted by {externalEmbedLabels[source]}. Do you want
to enable external media?
</Trans>
</Text>
<View style={[s.mt10]} />
<Text style={pal.textLight}>
<Trans>
External media may allow websites to collect information about you and
your device. No information is sent or requested until you press the
"play" button.
</Trans>
</Text>
<View style={[s.mt20]} />
<TouchableOpacity
testID="enableAllBtn"
onPress={onShowAllPress}
accessibilityRole="button"
accessibilityLabel={_(
msg`Show embeds from ${externalEmbedLabels[source]}`,
)}
accessibilityHint=""
onAccessibilityEscape={closeModal}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text style={[s.white, s.bold, s.f18]}>
<Trans>Enable External Media</Trans>
</Text>
</LinearGradient>
</TouchableOpacity>
<View style={[s.mt10]} />
<TouchableOpacity
testID="enableSourceBtn"
onPress={onShowPress}
accessibilityRole="button"
accessibilityLabel={_(
msg`Never load embeds from ${externalEmbedLabels[source]}`,
)}
accessibilityHint=""
onAccessibilityEscape={closeModal}>
<View style={[styles.btn, pal.btn]}>
<Text style={[pal.text, s.bold, s.f18]}>
<Trans>Enable {externalEmbedLabels[source]} only</Trans>
</Text>
</View>
</TouchableOpacity>
<View style={[s.mt10]} />
<TouchableOpacity
testID="disableSourceBtn"
onPress={onHidePress}
accessibilityRole="button"
accessibilityLabel={_(
msg`Never load embeds from ${externalEmbedLabels[source]}`,
)}
accessibilityHint=""
onAccessibilityEscape={closeModal}>
<View style={[styles.btn, pal.btn]}>
<Text style={[pal.text, s.bold, s.f18]}>
<Trans>No thanks</Trans>
</Text>
</View>
</TouchableOpacity>
</ScrollView>
)
}
const styles = StyleSheet.create({
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
marginBottom: 12,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 14,
backgroundColor: colors.gray1,
},
})
+11 -8
View File
@@ -18,7 +18,7 @@ import {ScrollView} from './util'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Trans} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {cleanError} from 'lib/strings/errors'
import {useModalControls} from '#/state/modals'
import {useInvitesState, useInvitesAPI} from '#/state/invites'
@@ -30,6 +30,7 @@ import {
useInviteCodesQuery,
InviteCodesQueryResponse,
} from '#/state/queries/invites'
import {useLingui} from '@lingui/react'
export const snapPoints = ['70%']
@@ -49,6 +50,7 @@ export function Component() {
export function Inner({invites}: {invites: InviteCodesQueryResponse}) {
const pal = usePalette('default')
const {_} = useLingui()
const {closeModal} = useModalControls()
const {isTabletOrDesktop} = useWebMediaQueries()
@@ -75,7 +77,7 @@ export function Inner({invites}: {invites: InviteCodesQueryResponse}) {
]}>
<Button
type="primary"
label="Done"
label={_(msg`Done`)}
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onClose}
@@ -118,7 +120,7 @@ export function Inner({invites}: {invites: InviteCodesQueryResponse}) {
<Button
testID="closeBtn"
type="primary"
label="Done"
label={_(msg`Done`)}
style={styles.btn}
labelStyle={styles.btnLabel}
onPress={onClose}
@@ -140,15 +142,16 @@ function InviteCode({
invites: InviteCodesQueryResponse
}) {
const pal = usePalette('default')
const {_} = useLingui()
const invitesState = useInvitesState()
const {setInviteCopied} = useInvitesAPI()
const uses = invite.uses
const onPress = React.useCallback(() => {
Clipboard.setString(invite.code)
Toast.show('Copied to clipboard')
Toast.show(_(msg`Copied to clipboard`))
setInviteCopied(invite.code)
}, [setInviteCopied, invite])
}, [setInviteCopied, invite, _])
return (
<View
@@ -163,10 +166,10 @@ function InviteCode({
accessibilityRole="button"
accessibilityLabel={
invites.available.length === 1
? 'Invite codes: 1 available'
: `Invite codes: ${invites.available.length} available`
? _(msg`Invite codes: 1 available`)
: _(msg`Invite codes: ${invites.available.length} available`)
}
accessibilityHint="Opens list of invite codes">
accessibilityHint={_(msg`Opens list of invite codes`)}>
<Text
testID={`${testID}-code`}
type={used ? 'md' : 'md-bold'}
+3 -3
View File
@@ -67,7 +67,7 @@ export function Component({
<TextInput
testID="searchInput"
style={[styles.searchInput, pal.border, pal.text]}
placeholder="Search for users"
placeholder={_(msg`Search for users`)}
placeholderTextColor={pal.colors.textLight}
value={query}
onChangeText={setQuery}
@@ -85,7 +85,7 @@ export function Component({
onPress={onPressCancelSearch}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel search`)}
accessibilityHint="Exits inputting search query"
accessibilityHint={_(msg`Exits inputting search query`)}
onAccessibilityEscape={onPressCancelSearch}
hitSlop={HITSLOP_20}>
<FontAwesomeIcon
@@ -141,7 +141,7 @@ export function Component({
}}
accessibilityLabel={_(msg`Done`)}
accessibilityHint=""
label="Done"
label={_(msg({message: 'Done', context: 'action'}))}
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
+4
View File
@@ -38,6 +38,7 @@ import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
import * as SwitchAccountModal from './SwitchAccount'
import * as LinkWarningModal from './LinkWarning'
import * as EmbedConsentModal from './EmbedConsent'
const DEFAULT_SNAPPOINTS = ['90%']
const HANDLE_HEIGHT = 24
@@ -176,6 +177,9 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'link-warning') {
snapPoints = LinkWarningModal.snapPoints
element = <LinkWarningModal.Component {...activeModal} />
} else if (activeModal?.name === 'embed-consent') {
snapPoints = EmbedConsentModal.snapPoints
element = <EmbedConsentModal.Component {...activeModal} />
} else {
return null
}
+3
View File
@@ -35,6 +35,7 @@ import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail'
import * as LinkWarningModal from './LinkWarning'
import * as EmbedConsentModal from './EmbedConsent'
export function ModalsContainer() {
const {isModalActive, activeModals} = useModals()
@@ -131,6 +132,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ChangeEmailModal.Component />
} else if (modal.name === 'link-warning') {
element = <LinkWarningModal.Component {...modal} />
} else if (modal.name === 'embed-consent') {
element = <EmbedConsentModal.Component {...modal} />
} else {
return null
}
+27 -18
View File
@@ -10,6 +10,8 @@ import {isWeb} from 'platform/detection'
import {listUriToHref} from 'lib/strings/url-helpers'
import {Button} from '../util/forms/Button'
import {useModalControls} from '#/state/modals'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
export const snapPoints = [300]
@@ -23,19 +25,21 @@ export function Component({
const {closeModal} = useModalControls()
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const {_} = useLingui()
let name
let description
if (!moderation.cause) {
name = 'Content Warning'
description =
'Moderator has chosen to set a general warning on the content.'
name = _(msg`Content Warning`)
description = _(
msg`Moderator has chosen to set a general warning on the content.`,
)
} else if (moderation.cause.type === 'blocking') {
if (moderation.cause.source.type === 'list') {
const list = moderation.cause.source.list
name = 'User Blocked by List'
name = _(msg`User Blocked by List`)
description = (
<>
<Trans>
This user is included in the{' '}
<TextLink
type="2xl"
@@ -44,25 +48,30 @@ export function Component({
style={pal.link}
/>{' '}
list which you have blocked.
</>
</Trans>
)
} else {
name = 'User Blocked'
description = 'You have blocked this user. You cannot view their content.'
name = _(msg`User Blocked`)
description = _(
msg`You have blocked this user. You cannot view their content.`,
)
}
} else if (moderation.cause.type === 'blocked-by') {
name = 'User Blocks You'
description = 'This user has blocked you. You cannot view their content.'
name = _(msg`User Blocks You`)
description = _(
msg`This user has blocked you. You cannot view their content.`,
)
} else if (moderation.cause.type === 'block-other') {
name = 'Content Not Available'
description =
'This content is not available because one of the users involved has blocked the 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 (moderation.cause.type === 'muted') {
if (moderation.cause.source.type === 'list') {
const list = moderation.cause.source.list
name = <>Account Muted by List</>
name = _(msg`Account Muted by List`)
description = (
<>
<Trans>
This user is included the{' '}
<TextLink
type="2xl"
@@ -71,11 +80,11 @@ export function Component({
style={pal.link}
/>{' '}
list which you have muted.
</>
</Trans>
)
} else {
name = 'Account Muted'
description = 'You have muted this user.'
name = _(msg`Account Muted`)
description = _(msg`You have muted this user.`)
}
} else {
name = moderation.cause.labelDef.strings[context].en.name
+7 -4
View File
@@ -14,11 +14,14 @@ import {ErrorScreen} from '../util/error/ErrorScreen'
import {CenteredView} from '../util/Views'
import {cleanError} from '#/lib/strings/errors'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export const snapPoints = [520, '100%']
export function Component({did}: {did: string}) {
const pal = usePalette('default')
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {
data: profile,
@@ -43,7 +46,7 @@ export function Component({did}: {did: string}) {
if (profileError) {
return (
<ErrorScreen
title="Oops!"
title={_(msg`Oops!`)}
message={cleanError(profileError)}
onPressTryAgain={refetchProfile}
/>
@@ -55,8 +58,8 @@ export function Component({did}: {did: string}) {
// should never happen
return (
<ErrorScreen
title="Oops!"
message="Something went wrong and we're not sure what."
title={_(msg`Oops!`)}
message={_(msg`Something went wrong and we're not sure what.`)}
onPressTryAgain={refetchProfile}
/>
)
@@ -104,7 +107,7 @@ function ComponentLoaded({
<>
<InfoCircleIcon size={21} style={pal.textLight} />
<ThemedText type="xl" fg="light">
Swipe up to see more
<Trans>Swipe up to see more</Trans>
</ThemedText>
</>
)}
+19 -5
View File
@@ -37,11 +37,23 @@ export function Component({
style={[styles.actionBtn]}
onPress={onRepost}
accessibilityRole="button"
accessibilityLabel={isReposted ? 'Undo repost' : 'Repost'}
accessibilityHint={isReposted ? 'Remove repost' : 'Repost '}>
accessibilityLabel={
isReposted
? _(msg`Undo repost`)
: _(msg({message: `Repost`, context: 'action'}))
}
accessibilityHint={
isReposted
? _(msg`Remove repost`)
: _(msg({message: `Repost`, context: 'action'}))
}>
<RepostIcon strokeWidth={2} size={24} style={s.blue3} />
<Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
<Trans>{!isReposted ? 'Repost' : 'Undo repost'}</Trans>
{!isReposted ? (
<Trans context="action">Repost</Trans>
) : (
<Trans>Undo repost</Trans>
)}
</Text>
</TouchableOpacity>
<TouchableOpacity
@@ -49,11 +61,13 @@ export function Component({
style={[styles.actionBtn]}
onPress={onQuote}
accessibilityRole="button"
accessibilityLabel={_(msg`Quote post`)}
accessibilityLabel={_(
msg({message: `Quote post`, context: 'action'}),
)}
accessibilityHint="">
<FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} />
<Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
<Trans>Quote Post</Trans>
<Trans context="action">Quote Post</Trans>
</Text>
</TouchableOpacity>
</View>
+4 -4
View File
@@ -92,7 +92,7 @@ export function Component({
testID="sexualLabelBtn"
selected={selected.includes('sexual')}
left
label="Suggestive"
label={_(msg`Suggestive`)}
onSelect={() => toggleAdultLabel('sexual')}
accessibilityHint=""
style={s.flex1}
@@ -100,7 +100,7 @@ export function Component({
<SelectableBtn
testID="nudityLabelBtn"
selected={selected.includes('nudity')}
label="Nudity"
label={_(msg`Nudity`)}
onSelect={() => toggleAdultLabel('nudity')}
accessibilityHint=""
style={s.flex1}
@@ -108,7 +108,7 @@ export function Component({
<SelectableBtn
testID="pornLabelBtn"
selected={selected.includes('porn')}
label="Porn"
label={_(msg`Porn`)}
right
onSelect={() => toggleAdultLabel('porn')}
accessibilityHint=""
@@ -154,7 +154,7 @@ export function Component({
accessibilityLabel={_(msg`Confirm`)}
accessibilityHint="">
<Text style={[s.white, s.bold, s.f18]}>
<Trans>Done</Trans>
<Trans context="action">Done</Trans>
</Text>
</TouchableOpacity>
</View>
+4 -2
View File
@@ -101,7 +101,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
onChangeText={setCustomUrl}
accessibilityLabel={_(msg`Custom domain`)}
// TODO: Simplify this wording further to be understandable by everyone
accessibilityHint="Use your domain as your Bluesky client service provider"
accessibilityHint={_(
msg`Use your domain as your Bluesky client service provider`,
)}
/>
<TouchableOpacity
testID="customServerSelectBtn"
@@ -110,7 +112,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) {
accessibilityRole="button"
accessibilityLabel={`Confirm service. ${
customUrl === ''
? 'Button disabled. Input custom domain to proceed.'
? _(msg`Button disabled. Input custom domain to proceed.`)
: ''
}`}
accessibilityHint=""
+5 -3
View File
@@ -62,7 +62,9 @@ function SwitchAccountCard({account}: {account: SessionAccount}) {
onPress={isSwitchingAccounts ? undefined : onPressSignout}
accessibilityRole="button"
accessibilityLabel={_(msg`Sign out`)}
accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}>
accessibilityHint={_(
msg`Signs ${profile?.displayName} out of Bluesky`,
)}>
<Text type="lg" style={pal.link}>
<Trans>Sign out</Trans>
</Text>
@@ -92,8 +94,8 @@ function SwitchAccountCard({account}: {account: SessionAccount}) {
isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account)
}
accessibilityRole="button"
accessibilityLabel={`Switch to ${account.handle}`}
accessibilityHint="Switches the account you are logged in to">
accessibilityLabel={_(msg`Switch to ${account.handle}`)}
accessibilityHint={_(msg`Switches the account you are logged in to`)}>
{contents}
</TouchableOpacity>
)
+4 -4
View File
@@ -69,7 +69,7 @@ export function Component({
<ScrollView>
<Text style={[pal.text, styles.description]}>
Choose "Everybody" or "Nobody"
<Trans>Choose "Everybody" or "Nobody"</Trans>
</Text>
<View style={{flexDirection: 'row', gap: 6, paddingHorizontal: 6}}>
<Selectable
@@ -86,7 +86,7 @@ export function Component({
/>
</View>
<Text style={[pal.text, styles.description]}>
Or combine these options:
<Trans>Or combine these options:</Trans>
</Text>
<View style={{flexDirection: 'column', gap: 4, paddingHorizontal: 6}}>
<Selectable
@@ -126,10 +126,10 @@ export function Component({
}}
style={styles.btn}
accessibilityRole="button"
accessibilityLabel={_(msg`Done`)}
accessibilityLabel={_(msg({message: `Done`, context: 'action'}))}
accessibilityHint="">
<Text style={[s.white, s.bold, s.f18]}>
<Trans>Done</Trans>
<Trans context="action">Done</Trans>
</Text>
</TouchableOpacity>
</View>
+18 -8
View File
@@ -76,10 +76,10 @@ export function Component({
type="default"
onPress={onPressDone}
style={styles.footerBtn}
accessibilityLabel={_(msg`Done`)}
accessibilityLabel={_(msg({message: `Done`, context: 'action'}))}
accessibilityHint=""
onAccessibilityEscape={onPressDone}
label="Done"
label={_(msg({message: `Done`, context: 'action'}))}
/>
</View>
</View>
@@ -175,12 +175,22 @@ function ListItem({
{sanitizeDisplayName(list.name)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '}
{list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '}
by{' '}
{list.creator.did === currentAccount?.did
? 'you'
: sanitizeHandle(list.creator.handle, '@')}
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>User list by you</Trans>
) : (
<Trans>
User list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
{list.purpose === 'app.bsky.graph.defs#modlist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>Moderation list by you</Trans>
) : (
<Trans>
Moderation list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
</Text>
</View>
<View>
+23 -11
View File
@@ -75,7 +75,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
token: confirmationCode.trim(),
})
updateCurrentAccount({emailConfirmed: true})
Toast.show('Email verified')
Toast.show(_(msg`Email verified`))
closeModal()
} catch (e) {
setError(cleanError(String(e)))
@@ -97,9 +97,15 @@ export function Component({showReminder}: {showReminder?: boolean}) {
{stage === Stages.Reminder && <ReminderIllustration />}
<View style={styles.titleSection}>
<Text type="title-lg" style={[pal.text, styles.title]}>
{stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
{stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
{stage === Stages.Email ? 'Verify Your Email' : ''}
{stage === Stages.Reminder ? (
<Trans>Please Verify Your Email</Trans>
) : stage === Stages.Email ? (
<Trans>Verify Your Email</Trans>
) : stage === Stages.ConfirmCode ? (
<Trans>Enter Confirmation Code</Trans>
) : (
''
)}
</Text>
</View>
@@ -133,7 +139,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
size={16}
/>
<Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}>
{currentAccount?.email || '(no email)'}
{currentAccount?.email || _(msg`(no email)`)}
</Text>
</View>
<Pressable
@@ -182,7 +188,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
onPress={() => setStage(Stages.Email)}
accessibilityLabel={_(msg`Get Started`)}
accessibilityHint=""
label="Get Started"
label={_(msg`Get Started`)}
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
@@ -195,7 +201,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
onPress={onSendEmail}
accessibilityLabel={_(msg`Send Confirmation Email`)}
accessibilityHint=""
label="Send Confirmation Email"
label={_(msg`Send Confirmation Email`)}
labelContainerStyle={{
justifyContent: 'center',
padding: 4,
@@ -207,7 +213,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
type="default"
accessibilityLabel={_(msg`I have a code`)}
accessibilityHint=""
label="I have a confirmation code"
label={_(msg`I have a confirmation code`)}
labelContainerStyle={{
justifyContent: 'center',
padding: 4,
@@ -224,7 +230,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
onPress={onConfirm}
accessibilityLabel={_(msg`Confirm`)}
accessibilityHint=""
label="Confirm"
label={_(msg`Confirm`)}
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
@@ -236,10 +242,16 @@ export function Component({showReminder}: {showReminder?: boolean}) {
closeModal()
}}
accessibilityLabel={
stage === Stages.Reminder ? 'Not right now' : 'Cancel'
stage === Stages.Reminder
? _(msg`Not right now`)
: _(msg`Cancel`)
}
accessibilityHint=""
label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
label={
stage === Stages.Reminder
? _(msg`Not right now`)
: _(msg`Cancel`)
}
labelContainerStyle={{justifyContent: 'center', padding: 4}}
labelStyle={[s.f18]}
/>
+11 -5
View File
@@ -48,7 +48,7 @@ export function Component({}: {}) {
} else {
setError(
resBody.error ||
'Something went wrong. Check your email and try again.',
_(msg`Something went wrong. Check your email and try again.`),
)
}
} catch (e: any) {
@@ -75,7 +75,7 @@ export function Component({}: {}) {
</Text>
<TextInput
style={[styles.textInput, pal.borderDark, pal.text, s.mb10, s.mt10]}
placeholder="Enter your email"
placeholder={_(msg`Enter your email`)}
placeholderTextColor={pal.textLight.color}
autoCapitalize="none"
autoCorrect={false}
@@ -86,7 +86,9 @@ export function Component({}: {}) {
enterKeyHint="done"
accessible={true}
accessibilityLabel={_(msg`Email`)}
accessibilityHint="Input your email to get on the Bluesky waitlist"
accessibilityHint={_(
msg`Input your email to get on the Bluesky waitlist`,
)}
/>
{error ? (
<View style={s.mt10}>
@@ -114,7 +116,9 @@ export function Component({}: {}) {
<TouchableOpacity
onPress={onPressSignup}
accessibilityRole="button"
accessibilityHint={`Confirms signing up ${email} to the waitlist`}>
accessibilityHint={_(
msg`Confirms signing up ${email} to the waitlist`,
)}>
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
@@ -130,7 +134,9 @@ export function Component({}: {}) {
onPress={onCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel waitlist signup`)}
accessibilityHint={`Exits signing up for waitlist with ${email}`}
accessibilityHint={_(
msg`Exits signing up for waitlist with ${email}`,
)}
onAccessibilityEscape={onCancel}>
<Text type="button-lg" style={pal.textLight}>
<Trans>Cancel</Trans>
@@ -42,7 +42,8 @@ export function InputIssueDetails({
accessibilityHint="Add more details to your report">
<FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} />
<Text style={[pal.text, s.f18, pal.link]}>
<Trans> Back</Trans>
{' '}
<Trans>Back</Trans>
</Text>
</TouchableOpacity>
<View style={[pal.btn, styles.detailsInputContainer]}>
+3 -3
View File
@@ -44,9 +44,9 @@ export function Component(content: ReportComponentProps) {
const {isMobile} = useWebMediaQueries()
const [isProcessing, setIsProcessing] = useState(false)
const [showDetailsInput, setShowDetailsInput] = useState(false)
const [error, setError] = useState<string>()
const [issue, setIssue] = useState<string>()
const [details, setDetails] = useState<string>()
const [error, setError] = useState<string>('')
const [issue, setIssue] = useState<string>('')
const [details, setDetails] = useState<string>('')
const isAccountReport = 'did' in content
const subjectKey = isAccountReport ? content.did : content.uri
const atUri = useMemo(
+8 -3
View File
@@ -13,6 +13,8 @@ import {logger} from '#/logger'
import {cleanError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/queries/preferences'
import {List, ListRef} from '../util/List'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
@@ -31,6 +33,7 @@ export function Feed({
}) {
const [isPTRing, setIsPTRing] = React.useState(false)
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {checkUnread} = useUnreadNotificationsApi()
const {
@@ -101,14 +104,16 @@ export function Feed({
return (
<EmptyState
icon="bell"
message="No notifications yet!"
message={_(msg`No notifications yet!`)}
style={styles.emptyState}
/>
)
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching notifications. Tap here to try again."
label={_(
msg`There was an issue fetching notifications. Tap here to try again.`,
)}
onPress={onPressRetryLoadMore}
/>
)
@@ -117,7 +122,7 @@ export function Feed({
}
return <FeedItem item={item} moderationOpts={moderationOpts!} />
},
[onPressRetryLoadMore, moderationOpts],
[onPressRetryLoadMore, moderationOpts, _],
)
const FeedFooter = React.useCallback(
+31 -10
View File
@@ -42,6 +42,7 @@ import {TimeElapsed} from '../util/TimeElapsed'
import {isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FeedSourceCard} from '../feeds/FeedSourceCard'
const MAX_AUTHORS = 5
@@ -64,6 +65,7 @@ let FeedItem = ({
moderationOpts: ModerationOpts
}): React.ReactNode => {
const pal = usePalette('default')
const {_} = useLingui()
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
const itemHref = useMemo(() => {
if (item.type === 'post-like' || item.type === 'repost') {
@@ -112,7 +114,7 @@ let FeedItem = ({
]
}, [item, moderationOpts])
if (item.subjectUri && !item.subject) {
if (item.subjectUri && !item.subject && item.type !== 'feedgen-like') {
// don't render anything if the target post was deleted or unfindable
return <View />
}
@@ -150,24 +152,26 @@ let FeedItem = ({
let icon: Props['icon'] | 'HeartIconSolid'
let iconStyle: Props['style'] = []
if (item.type === 'post-like') {
action = 'liked your post'
action = _(msg`liked your post`)
icon = 'HeartIconSolid'
iconStyle = [
s.likeColor as FontAwesomeIconStyle,
{position: 'relative', top: -4},
]
} else if (item.type === 'repost') {
action = 'reposted your post'
action = _(msg`reposted your post`)
icon = 'retweet'
iconStyle = [s.green3 as FontAwesomeIconStyle]
} else if (item.type === 'follow') {
action = 'followed you'
action = _(msg`followed you`)
icon = 'user-plus'
iconStyle = [s.blue3 as FontAwesomeIconStyle]
} else if (item.type === 'feedgen-like') {
action = `liked your custom feed${
item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : ''
}`
action = _(
msg`liked your custom feed${
item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}'` : ''
}`,
)
icon = 'HeartIconSolid'
iconStyle = [
s.likeColor as FontAwesomeIconStyle,
@@ -256,6 +260,13 @@ let FeedItem = ({
{item.type === 'post-like' || item.type === 'repost' ? (
<AdditionalPostText post={item.subject} />
) : null}
{item.type === 'feedgen-like' && item.subjectUri ? (
<FeedSourceCard
feedUri={item.subjectUri}
style={[pal.view, pal.border, styles.feedcard]}
showLikes
/>
) : null}
</View>
</Link>
)
@@ -306,14 +317,16 @@ function CondensedAuthorsList({
onPress={onToggleAuthorsExpanded}
accessibilityRole="button"
accessibilityLabel={_(msg`Hide user list`)}
accessibilityHint="Collapses list of users for a given notification">
accessibilityHint={_(
msg`Collapses list of users for a given notification`,
)}>
<FontAwesomeIcon
icon="angle-up"
size={18}
style={[styles.expandedAuthorsCloseBtnIcon, pal.text]}
/>
<Text type="sm-medium" style={pal.text}>
<Trans>Hide</Trans>
<Trans context="action">Hide</Trans>
</Text>
</TouchableOpacity>
</View>
@@ -335,7 +348,9 @@ function CondensedAuthorsList({
return (
<TouchableOpacity
accessibilityLabel={_(msg`Show users`)}
accessibilityHint="Opens an expanded list of users in this notification"
accessibilityHint={_(
msg`Opens an expanded list of users in this notification`,
)}
onPress={onToggleAuthorsExpanded}>
<View style={styles.avis}>
{authors.slice(0, MAX_AUTHORS).map(author => (
@@ -496,6 +511,12 @@ const styles = StyleSheet.create({
marginLeft: 2,
opacity: 0.8,
},
feedcard: {
borderWidth: 1,
borderRadius: 8,
paddingVertical: 12,
marginTop: 6,
},
addedContainer: {
paddingTop: 4,
+3 -1
View File
@@ -74,7 +74,9 @@ export function FeedsTabBar(
onPress={onPressAvi}
accessibilityRole="button"
accessibilityLabel={_(msg`Open navigation`)}
accessibilityHint="Access profile and other navigation links"
accessibilityHint={_(
msg`Access profile and other navigation links`,
)}
hitSlop={HITSLOP_10}>
<FontAwesomeIcon
icon="bars"
+6 -2
View File
@@ -240,7 +240,11 @@ function PostThreadLoaded({
const renderItem = React.useCallback(
({item, index}: {item: YieldedItem; index: number}) => {
if (item === TOP_COMPONENT) {
return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null
return isTablet ? (
<ViewHeader
title={_(msg({message: `Post`, context: 'description'}))}
/>
) : null
} else if (item === PARENT_SPINNER) {
return (
<View style={styles.parentSpinner}>
@@ -411,7 +415,7 @@ function PostThreadBlocked() {
style={[pal.link as FontAwesomeIconStyle, s.mr5]}
size={14}
/>
Back
<Trans context="action">Back</Trans>
</Text>
</TouchableOpacity>
</View>
+15 -9
View File
@@ -5,9 +5,9 @@ import {
AppBskyFeedDefs,
AppBskyFeedPost,
RichText as RichTextAPI,
moderatePost,
PostModeration,
} from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Link, TextLink} from '../util/Link'
import {RichText} from '../util/text/RichText'
@@ -158,6 +158,7 @@ let PostThreadItemLoaded = ({
onPostReply: () => void
}): React.ReactNode => {
const pal = usePalette('default')
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const {openComposer} = useComposerControls()
const {currentAccount} = useSession()
@@ -172,7 +173,7 @@ let PostThreadItemLoaded = ({
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const itemTitle = `Post by ${post.author.handle}`
const itemTitle = _(msg`Post by ${post.author.handle}`)
const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle
const isAuthorMuted = post.author.viewer?.muted
@@ -180,15 +181,15 @@ let PostThreadItemLoaded = ({
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
}, [post.uri, post.author])
const likesTitle = 'Likes on this post'
const likesTitle = _(msg`Likes on this post`)
const repostsHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
}, [post.uri, post.author])
const repostsTitle = 'Reposts of this post'
const isSelfLabeledPost =
const repostsTitle = _(msg`Reposts of this post`)
const isModeratedPost =
moderation.decisions.post.cause?.type === 'label' &&
moderation.decisions.post.cause.label.src === currentAccount?.did
moderation.decisions.post.cause.label.src !== currentAccount?.did
const translatorUrl = getTranslatorLink(
record?.text || '',
@@ -214,6 +215,7 @@ let PostThreadItemLoaded = ({
displayName: post.author.displayName,
avatar: post.author.avatar,
},
embed: post.embed,
},
onPost: onPostReply,
})
@@ -224,7 +226,7 @@ let PostThreadItemLoaded = ({
}, [setLimitLines])
if (!record) {
return <ErrorMessage message="Invalid or unsupported post record" />
return <ErrorMessage message={_(msg`Invalid or unsupported post record`)} />
}
if (isHighlightedPost) {
@@ -334,8 +336,9 @@ let PostThreadItemLoaded = ({
postCid={post.cid}
postUri={post.uri}
record={record}
richText={richText}
showAppealLabelItem={
post.author.did === currentAccount?.did && !isSelfLabeledPost
post.author.did === currentAccount?.did && isModeratedPost
}
style={{
paddingVertical: 6,
@@ -437,6 +440,7 @@ let PostThreadItemLoaded = ({
big
post={post}
record={record}
richText={richText}
onPressReply={onPressReply}
/>
</View>
@@ -539,6 +543,7 @@ let PostThreadItemLoaded = ({
timestamp={post.indexedAt}
postHref={postHref}
showAvatar={isThreadedChild}
avatarModeration={moderation.avatar}
avatarSize={28}
displayNameType="md-bold"
displayNameStyle={isThreadedChild && s.ml2}
@@ -561,7 +566,7 @@ let PostThreadItemLoaded = ({
) : undefined}
{limitLines ? (
<TextLink
text="Show More"
text={_(msg`Show More`)}
style={pal.link}
onPress={onPressShowMore}
href="#"
@@ -584,6 +589,7 @@ let PostThreadItemLoaded = ({
<PostCtrls
post={post}
record={record}
richText={richText}
onPressReply={onPressReply}
/>
</View>
+22 -10
View File
@@ -4,10 +4,10 @@ import {
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
moderatePost,
PostModeration,
RichText as RichTextAPI,
} from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Link, TextLink} from '../util/Link'
import {UserInfoText} from '../util/UserInfoText'
@@ -27,6 +27,8 @@ import {countLines} from 'lib/strings/helpers'
import {useModerationOpts} from '#/state/queries/preferences'
import {useComposerControls} from '#/state/shell/composer'
import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function Post({
post,
@@ -95,6 +97,7 @@ function PostInner({
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {openComposer} = useComposerControls()
const [limitLines, setLimitLines] = useState(
() => countLines(richText?.text) >= MAX_POST_LINES,
@@ -118,6 +121,7 @@ function PostInner({
displayName: post.author.displayName,
avatar: post.author.avatar,
},
embed: post.embed,
},
})
}, [openComposer, post, record])
@@ -158,13 +162,15 @@ function PostInner({
style={[pal.textLight, s.mr2]}
lineHeight={1.2}
numberOfLines={1}>
Reply to{' '}
<UserInfoText
type="sm"
did={replyAuthorDid}
attr="displayName"
style={[pal.textLight]}
/>
<Trans context="description">
Reply to{' '}
<UserInfoText
type="sm"
did={replyAuthorDid}
attr="displayName"
style={[pal.textLight]}
/>
</Trans>
</Text>
</View>
)}
@@ -187,7 +193,7 @@ function PostInner({
) : undefined}
{limitLines ? (
<TextLink
text="Show More"
text={_(msg`Show More`)}
style={pal.link}
onPress={onPressShowMore}
href="#"
@@ -207,7 +213,12 @@ function PostInner({
</ContentHider>
) : null}
</ContentHider>
<PostCtrls post={post} record={record} onPressReply={onPressReply} />
<PostCtrls
post={post}
record={record}
richText={richText}
onPressReply={onPressReply}
/>
</View>
</View>
</Link>
@@ -221,6 +232,7 @@ const styles = StyleSheet.create({
paddingBottom: 5,
paddingLeft: 10,
borderTopWidth: 1,
// @ts-ignore web only -prf
cursor: 'pointer',
},
layout: {
+6 -3
View File
@@ -12,6 +12,7 @@ import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {Trans} from '@lingui/macro'
export function CustomFeedEmptyState() {
const pal = usePalette('default')
@@ -33,15 +34,17 @@ export function CustomFeedEmptyState() {
<MagnifyingGlassIcon style={[styles.emptyIcon, pal.text]} size={62} />
</View>
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
This feed is empty! You may need to follow more users or tune your
language settings.
<Trans>
This feed is empty! You may need to follow more users or tune your
language settings.
</Trans>
</Text>
<Button
type="inverted"
style={styles.emptyBtn}
onPress={onPressFindAccounts}>
<Text type="lg-medium" style={palInverted.text}>
Find accounts to follow
<Trans>Find accounts to follow</Trans>
</Text>
<FontAwesomeIcon
icon="angle-right"
+20 -8
View File
@@ -28,13 +28,16 @@ import {isWeb} from '#/platform/detection'
import {listenPostCreated} from '#/state/events'
import {useSession} from '#/state/session'
import {STALE} from '#/state/queries'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
const LOADING_ITEM = {_reactKey: '__loading__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const ERROR_ITEM = {_reactKey: '__error__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
const REFRESH_AFTER = STALE.HOURS.ONE
// DISABLED need to check if this is causing random feed refreshes -prf
// const REFRESH_AFTER = STALE.HOURS.ONE
const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
let Feed = ({
@@ -44,6 +47,7 @@ let Feed = ({
style,
enabled,
pollInterval,
disablePoll,
scrollElRef,
onScrolledDownChange,
onHasNew,
@@ -61,6 +65,7 @@ let Feed = ({
style?: StyleProp<ViewStyle>
enabled?: boolean
pollInterval?: number
disablePoll?: boolean
scrollElRef?: ListRef
onHasNew?: (v: boolean) => void
onScrolledDownChange?: (isScrolledDown: boolean) => void
@@ -74,6 +79,7 @@ let Feed = ({
}): React.ReactNode => {
const theme = useTheme()
const {track} = useAnalytics()
const {_} = useLingui()
const queryClient = useQueryClient()
const {currentAccount} = useSession()
const [isPTRing, setIsPTRing] = React.useState(false)
@@ -95,13 +101,16 @@ let Feed = ({
isFetchingNextPage,
fetchNextPage,
} = usePostFeedQuery(feed, feedParams, opts)
const isEmpty = !isFetching && !data?.pages[0]?.slices.length
if (data?.pages[0]) {
lastFetchRef.current = data?.pages[0].fetchedAt
}
const isEmpty = React.useMemo(
() => !isFetching && !data?.pages?.some(page => page.slices.length),
[isFetching, data],
)
const checkForNew = React.useCallback(async () => {
if (!data?.pages[0] || isFetching || !onHasNew || !enabled) {
if (!data?.pages[0] || isFetching || !onHasNew || !enabled || disablePoll) {
return
}
try {
@@ -111,7 +120,7 @@ let Feed = ({
} catch (e) {
logger.error('Poll latest failed', {feed, error: String(e)})
}
}, [feed, data, isFetching, onHasNew, enabled])
}, [feed, data, isFetching, onHasNew, enabled, disablePoll])
const myDid = currentAccount?.did || ''
const onPostCreated = React.useCallback(() => {
@@ -140,11 +149,12 @@ let Feed = ({
React.useEffect(() => {
if (enabled) {
const timeSinceFirstLoad = Date.now() - lastFetchRef.current
if (timeSinceFirstLoad > REFRESH_AFTER) {
// DISABLED need to check if this is causing random feed refreshes -prf
/*if (timeSinceFirstLoad > REFRESH_AFTER) {
// do a full refresh
scrollElRef?.current?.scrollToOffset({offset: 0, animated: false})
queryClient.resetQueries({queryKey: RQKEY(feed)})
} else if (
} else*/ if (
timeSinceFirstLoad > CHECK_LATEST_AFTER &&
checkForNewRef.current
) {
@@ -247,7 +257,9 @@ let Feed = ({
} else if (item === LOAD_MORE_ERROR_ITEM) {
return (
<LoadMoreRetryBtn
label="There was an issue fetching posts. Tap here to try again."
label={_(
msg`There was an issue fetching posts. Tap here to try again.`,
)}
onPress={onPressRetryLoadMore}
/>
)
@@ -256,7 +268,7 @@ let Feed = ({
}
return <FeedSlice slice={item} />
},
[feed, error, onPressTryAgain, onPressRetryLoadMore, renderEmptyState],
[feed, error, onPressTryAgain, onPressRetryLoadMore, renderEmptyState, _],
)
const shouldRenderEndOfFeed =
+9 -6
View File
@@ -38,6 +38,7 @@ export function FeedErrorMessage({
error?: Error
onPressTryAgain: () => void
}) {
const {_: _l} = useLingui()
const knownError = React.useMemo(
() => detectKnownError(feedDesc, error),
[feedDesc, error],
@@ -60,7 +61,7 @@ export function FeedErrorMessage({
return (
<EmptyState
icon="ban"
message="Posts hidden"
message={_l(msgLingui`Posts hidden`)}
style={{paddingVertical: 40}}
/>
)
@@ -134,7 +135,9 @@ function FeedgenErrorMessage({
await removeFeed({uri})
} catch (err) {
Toast.show(
'There was an an issue removing this feed. Please check your internet connection and try again.',
_l(
msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`,
),
)
logger.error('Failed to remove feed', {error: err})
}
@@ -160,20 +163,20 @@ function FeedgenErrorMessage({
{knownError === KnownError.FeedgenDoesNotExist && (
<Button
type="inverted"
label="Remove feed"
label={_l(msgLingui`Remove feed`)}
onPress={onRemoveFeed}
/>
)}
<Button
type="default-light"
label="View profile"
label={_l(msgLingui`View profile`)}
onPress={onViewProfile}
/>
</View>
)
}
}
}, [knownError, onViewProfile, onRemoveFeed])
}, [knownError, onViewProfile, onRemoveFeed, _l])
return (
<View
@@ -191,7 +194,7 @@ function FeedgenErrorMessage({
{rawError?.message && (
<Text style={pal.textLight}>
<Trans>Message from server</Trans>: {rawError.message}
<Trans>Message from server: {rawError.message}</Trans>
</Text>
)}
+59 -32
View File
@@ -34,6 +34,9 @@ import {countLines} from 'lib/strings/helpers'
import {useComposerControls} from '#/state/shell/composer'
import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow'
import {FeedNameText} from '../util/FeedInfoText'
import {useSession} from '#/state/session'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function FeedItem({
post,
@@ -102,10 +105,15 @@ let FeedItemInner = ({
}): React.ReactNode => {
const {openComposer} = useComposerControls()
const pal = usePalette('default')
const {_} = useLingui()
const {currentAccount} = useSession()
const href = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const isModeratedPost =
moderation.decisions.post.cause?.type === 'label' &&
moderation.decisions.post.cause.label.src !== currentAccount?.did
const replyAuthorDid = useMemo(() => {
if (!record?.reply) {
@@ -126,6 +134,7 @@ let FeedItemInner = ({
displayName: post.author.displayName,
avatar: post.author.avatar,
},
embed: post.embed,
},
})
}, [post, record, openComposer])
@@ -176,24 +185,28 @@ let FeedItemInner = ({
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}>
From{' '}
<FeedNameText
type="sm-bold"
uri={reason.uri}
href={reason.href}
lineHeight={1.2}
numberOfLines={1}
style={pal.textLight}
/>
<Trans context="from-feed">
From{' '}
<FeedNameText
type="sm-bold"
uri={reason.uri}
href={reason.href}
lineHeight={1.2}
numberOfLines={1}
style={pal.textLight}
/>
</Trans>
</Text>
</Link>
) : AppBskyFeedDefs.isReasonRepost(reason) ? (
<Link
style={styles.includeReason}
href={makeProfileLink(reason.by)}
title={`Reposted by ${sanitizeDisplayName(
reason.by.displayName || reason.by.handle,
)}`}>
title={_(
msg`Reposted by ${sanitizeDisplayName(
reason.by.displayName || reason.by.handle,
)})`,
)}>
<FontAwesomeIcon
icon="retweet"
style={{
@@ -207,17 +220,19 @@ let FeedItemInner = ({
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}>
Reposted by{' '}
<TextLinkOnWebOnly
type="sm-bold"
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}
text={sanitizeDisplayName(
reason.by.displayName || sanitizeHandle(reason.by.handle),
)}
href={makeProfileLink(reason.by)}
/>
<Trans>
Reposted by{' '}
<TextLinkOnWebOnly
type="sm-bold"
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}
text={sanitizeDisplayName(
reason.by.displayName || sanitizeHandle(reason.by.handle),
)}
href={makeProfileLink(reason.by)}
/>
</Trans>
</Text>
</Link>
) : null}
@@ -268,13 +283,15 @@ let FeedItemInner = ({
style={[pal.textLight, s.mr2]}
lineHeight={1.2}
numberOfLines={1}>
Reply to{' '}
<UserInfoText
type="md"
did={replyAuthorDid}
attr="displayName"
style={[pal.textLight, s.ml2]}
/>
<Trans context="description">
Reply to{' '}
<UserInfoText
type="md"
did={replyAuthorDid}
attr="displayName"
style={[pal.textLight, s.ml2]}
/>
</Trans>
</Text>
</View>
)}
@@ -284,7 +301,15 @@ let FeedItemInner = ({
postEmbed={post.embed}
postAuthor={post.author}
/>
<PostCtrls post={post} record={record} onPressReply={onPressReply} />
<PostCtrls
post={post}
record={record}
richText={richText}
onPressReply={onPressReply}
showAppealLabelItem={
post.author.did === currentAccount?.did && isModeratedPost
}
/>
</View>
</View>
</Link>
@@ -304,6 +329,7 @@ let PostContent = ({
postAuthor: AppBskyFeedDefs.PostView['author']
}): React.ReactNode => {
const pal = usePalette('default')
const {_} = useLingui()
const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES,
)
@@ -333,7 +359,7 @@ let PostContent = ({
) : undefined}
{limitLines ? (
<TextLink
text="Show More"
text={_(msg`Show More`)}
style={pal.link}
onPress={onPressShowMore}
href="#"
@@ -364,6 +390,7 @@ const styles = StyleSheet.create({
borderTopWidth: 1,
paddingLeft: 10,
paddingRight: 15,
// @ts-ignore web only -prf
cursor: 'pointer',
overflow: 'hidden',
},
+2 -1
View File
@@ -8,6 +8,7 @@ import Svg, {Circle, Line} from 'react-native-svg'
import {FeedItem} from './FeedItem'
import {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links'
import {Trans} from '@lingui/macro'
let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
if (slice.isThread && slice.items.length > 3) {
@@ -99,7 +100,7 @@ function ViewFullThread({slice}: {slice: FeedPostSlice}) {
</View>
<Text type="md" style={[pal.link, {paddingTop: 18, paddingBottom: 4}]}>
View full thread
<Trans>View full thread</Trans>
</Text>
</Link>
)
+8 -5
View File
@@ -12,6 +12,7 @@ import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {Trans} from '@lingui/macro'
export function FollowingEmptyState() {
const pal = usePalette('default')
@@ -43,15 +44,17 @@ export function FollowingEmptyState() {
<MagnifyingGlassIcon style={[styles.icon, pal.text]} size={62} />
</View>
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
Your following feed is empty! Follow more users to see what's
happening.
<Trans>
Your following feed is empty! Follow more users to see what's
happening.
</Trans>
</Text>
<Button
type="inverted"
style={styles.emptyBtn}
onPress={onPressFindAccounts}>
<Text type="lg-medium" style={palInverted.text}>
Find accounts to follow
<Trans>Find accounts to follow</Trans>
</Text>
<FontAwesomeIcon
icon="angle-right"
@@ -61,14 +64,14 @@ export function FollowingEmptyState() {
</Button>
<Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
You can also discover new Custom Feeds to follow.
<Trans>You can also discover new Custom Feeds to follow.</Trans>
</Text>
<Button
type="inverted"
style={[styles.emptyBtn, s.mt10]}
onPress={onPressDiscoverFeeds}>
<Text type="lg-medium" style={palInverted.text}>
Discover new custom feeds
<Trans>Discover new custom feeds</Trans>
</Text>
<FontAwesomeIcon
icon="angle-right"
+8 -5
View File
@@ -11,6 +11,7 @@ import {NavigationProp} from 'lib/routes/types'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {Trans} from '@lingui/macro'
export function FollowingEndOfFeed() {
const pal = usePalette('default')
@@ -44,15 +45,17 @@ export function FollowingEndOfFeed() {
]}>
<View style={styles.inner}>
<Text type="xl-medium" style={[s.textCenter, pal.text]}>
You've reached the end of your feed! Find some more accounts to
follow.
<Trans>
You've reached the end of your feed! Find some more accounts to
follow.
</Trans>
</Text>
<Button
type="inverted"
style={styles.emptyBtn}
onPress={onPressFindAccounts}>
<Text type="lg-medium" style={palInverted.text}>
Find accounts to follow
<Trans>Find accounts to follow</Trans>
</Text>
<FontAwesomeIcon
icon="angle-right"
@@ -62,14 +65,14 @@ export function FollowingEndOfFeed() {
</Button>
<Text type="xl-medium" style={[s.textCenter, pal.text, s.mt20]}>
You can also discover new Custom Feeds to follow.
<Trans>You can also discover new Custom Feeds to follow.</Trans>
</Text>
<Button
type="inverted"
style={[styles.emptyBtn, s.mt10]}
onPress={onPressDiscoverFeeds}>
<Text type="lg-medium" style={palInverted.text}>
Discover new custom feeds
<Trans>Discover new custom feeds</Trans>
</Text>
<FontAwesomeIcon
icon="angle-right"
+7 -4
View File
@@ -5,6 +5,8 @@ import {Button, ButtonType} from '../util/forms/Button'
import * as Toast from '../util/Toast'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {Shadow} from '#/state/cache/types'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
export function FollowButton({
unfollowedType = 'inverted',
@@ -18,13 +20,14 @@ export function FollowButton({
labelStyle?: StyleProp<TextStyle>
}) {
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
const {_} = useLingui()
const onPressFollow = async () => {
try {
await queueFollow()
} catch (e: any) {
if (e?.name !== 'AbortError') {
Toast.show(`An issue occurred, please try again.`)
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
}
@@ -34,7 +37,7 @@ export function FollowButton({
await queueUnfollow()
} catch (e: any) {
if (e?.name !== 'AbortError') {
Toast.show(`An issue occurred, please try again.`)
Toast.show(_(msg`An issue occurred, please try again.`))
}
}
}
@@ -49,7 +52,7 @@ export function FollowButton({
type={followedType}
labelStyle={labelStyle}
onPress={onPressUnfollow}
label="Unfollow"
label={_(msg({message: 'Unfollow', context: 'action'}))}
/>
)
} else {
@@ -58,7 +61,7 @@ export function FollowButton({
type={unfollowedType}
labelStyle={labelStyle}
onPress={onPressFollow}
label="Follow"
label={_(msg({message: 'Follow', context: 'action'}))}
/>
)
}
+13 -4
View File
@@ -23,10 +23,12 @@ import {Shadow} from '#/state/cache/types'
import {useModerationOpts} from '#/state/queries/preferences'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
import {Trans} from '@lingui/macro'
export function ProfileCard({
testID,
profile: profileUnshadowed,
noModFilter,
noBg,
noBorder,
followers,
@@ -35,6 +37,7 @@ export function ProfileCard({
}: {
testID?: string
profile: AppBskyActorDefs.ProfileViewBasic
noModFilter?: boolean
noBg?: boolean
noBorder?: boolean
followers?: AppBskyActorDefs.ProfileView[] | undefined
@@ -50,7 +53,11 @@ export function ProfileCard({
return null
}
const moderation = moderateProfile(profile, moderationOpts)
if (moderation.account.filter) {
if (
!noModFilter &&
moderation.account.filter &&
moderation.account.cause?.type !== 'muted'
) {
return null
}
@@ -131,7 +138,7 @@ function ProfileCardPills({
{followedBy && (
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
Follows You
<Trans>Follows You</Trans>
</Text>
</View>
)}
@@ -184,8 +191,10 @@ function FollowersList({
style={[styles.followsByDesc, pal.textLight]}
numberOfLines={2}
lineHeight={1.2}>
Followed by{' '}
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
<Trans>
Followed by{' '}
{followersWithMods.map(({f}) => f.displayName || f.handle).join(', ')}
</Trans>
</Text>
{followersWithMods.slice(0, 3).map(({f, mod}) => (
<View key={f.did} style={styles.followedByAviContainer}>
+53 -37
View File
@@ -192,14 +192,16 @@ let ProfileHeaderLoaded = ({
track('ProfileHeader:FollowButtonClicked')
await queueFollow()
Toast.show(
`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
_(
msg`Following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
),
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to follow', {error: String(e)})
Toast.show(`There was an issue! ${e.toString()}`)
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
})
@@ -211,14 +213,16 @@ let ProfileHeaderLoaded = ({
track('ProfileHeader:UnfollowButtonClicked')
await queueUnfollow()
Toast.show(
`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
_(
msg`No longer following ${sanitizeDisplayName(
profile.displayName || profile.handle,
)}`,
),
)
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow', {error: String(e)})
Toast.show(`There was an issue! ${e.toString()}`)
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
})
@@ -253,27 +257,27 @@ let ProfileHeaderLoaded = ({
track('ProfileHeader:MuteAccountButtonClicked')
try {
await queueMute()
Toast.show('Account muted')
Toast.show(_(msg`Account muted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {error: e})
Toast.show(`There was an issue! ${e.toString()}`)
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [track, queueMute])
}, [track, queueMute, _])
const onPressUnmuteAccount = React.useCallback(async () => {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await queueUnmute()
Toast.show('Account unmuted')
Toast.show(_(msg`Account unmuted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {error: e})
Toast.show(`There was an issue! ${e.toString()}`)
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [track, queueUnmute])
}, [track, queueUnmute, _])
const onPressBlockAccount = React.useCallback(async () => {
track('ProfileHeader:BlockAccountButtonClicked')
@@ -286,11 +290,11 @@ let ProfileHeaderLoaded = ({
onPressConfirm: async () => {
try {
await queueBlock()
Toast.show('Account blocked')
Toast.show(_(msg`Account blocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {error: e})
Toast.show(`There was an issue! ${e.toString()}`)
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
},
@@ -308,11 +312,11 @@ let ProfileHeaderLoaded = ({
onPressConfirm: async () => {
try {
await queueUnblock()
Toast.show('Account unblocked')
Toast.show(_(msg`Account unblocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {error: e})
Toast.show(`There was an issue! ${e.toString()}`)
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
},
@@ -451,7 +455,9 @@ let ProfileHeaderLoaded = ({
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel={_(msg`Edit profile`)}
accessibilityHint="Opens editor for profile display name, avatar, background image, and description">
accessibilityHint={_(
msg`Opens editor for profile display name, avatar, background image, and description`,
)}>
<Text type="button" style={pal.text}>
<Trans>Edit Profile</Trans>
</Text>
@@ -466,7 +472,7 @@ let ProfileHeaderLoaded = ({
accessibilityLabel={_(msg`Unblock`)}
accessibilityHint="">
<Text type="button" style={[pal.text, s.bold]}>
<Trans>Unblock</Trans>
<Trans context="action">Unblock</Trans>
</Text>
</TouchableOpacity>
)
@@ -488,8 +494,12 @@ let ProfileHeaderLoaded = ({
},
]}
accessibilityRole="button"
accessibilityLabel={`Show follows similar to ${profile.handle}`}
accessibilityHint={`Shows a list of users similar to this user.`}>
accessibilityLabel={_(
msg`Show follows similar to ${profile.handle}`,
)}
accessibilityHint={_(
msg`Shows a list of users similar to this user.`,
)}>
<FontAwesomeIcon
icon="user-plus"
style={[
@@ -511,8 +521,10 @@ let ProfileHeaderLoaded = ({
onPress={onPressUnfollow}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel={`Unfollow ${profile.handle}`}
accessibilityHint={`Hides posts from ${profile.handle} in your feed`}>
accessibilityLabel={_(msg`Unfollow ${profile.handle}`)}
accessibilityHint={_(
msg`Hides posts from ${profile.handle} in your feed`,
)}>
<FontAwesomeIcon
icon="check"
style={[pal.text, s.mr5]}
@@ -528,8 +540,10 @@ let ProfileHeaderLoaded = ({
onPress={onPressFollow}
style={[styles.btn, styles.mainBtn, palInverted.view]}
accessibilityRole="button"
accessibilityLabel={`Follow ${profile.handle}`}
accessibilityHint={`Shows posts from ${profile.handle} in your feed`}>
accessibilityLabel={_(msg`Follow ${profile.handle}`)}
accessibilityHint={_(
msg`Shows posts from ${profile.handle} in your feed`,
)}>
<FontAwesomeIcon
icon="plus"
style={[palInverted.text, s.mr5]}
@@ -580,7 +594,7 @@ let ProfileHeaderLoaded = ({
invalidHandle ? styles.invalidHandle : undefined,
styles.handle,
]}>
{invalidHandle ? '⚠Invalid Handle' : `@${profile.handle}`}
{invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
</ThemedText>
</View>
{!blockHide && (
@@ -597,7 +611,7 @@ let ProfileHeaderLoaded = ({
}
asAnchor
accessibilityLabel={`${followers} ${pluralizedFollowers}`}
accessibilityHint={'Opens followers list'}>
accessibilityHint={_(msg`Opens followers list`)}>
<Text type="md" style={[s.bold, pal.text]}>
{followers}{' '}
</Text>
@@ -615,14 +629,16 @@ let ProfileHeaderLoaded = ({
})
}
asAnchor
accessibilityLabel={`${following} following`}
accessibilityHint={'Opens following list'}>
<Text type="md" style={[s.bold, pal.text]}>
{following}{' '}
</Text>
<Text type="md" style={[pal.textLight]}>
<Trans>following</Trans>
</Text>
accessibilityLabel={_(msg`${following} following`)}
accessibilityHint={_(msg`Opens following list`)}>
<Trans>
<Text type="md" style={[s.bold, pal.text]}>
{following}{' '}
</Text>
<Text type="md" style={[pal.textLight]}>
following
</Text>
</Trans>
</Link>
<Text type="md" style={[s.bold, pal.text]}>
{formatCount(profile.postsCount || 0)}{' '}
@@ -682,7 +698,7 @@ let ProfileHeaderLoaded = ({
testID="profileHeaderAviButton"
onPress={onPressAvi}
accessibilityRole="image"
accessibilityLabel={`View ${profile.handle}'s avatar`}
accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
accessibilityHint="">
<View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
@@ -21,6 +21,7 @@ import {useModerationOpts} from '#/state/queries/preferences'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {Trans} from '@lingui/macro'
const OUTER_PADDING = 10
const INNER_PADDING = 14
@@ -60,7 +61,7 @@ export function ProfileHeaderSuggestedFollows({
paddingRight: INNER_PADDING / 2,
}}>
<Text type="sm-bold" style={[pal.textLight]}>
Suggested for you
<Trans>Suggested for you</Trans>
</Text>
<Pressable
+11 -9
View File
@@ -16,7 +16,7 @@ import {BACK_HITSLOP} from 'lib/constants'
import {isNative} from 'platform/detection'
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {useSetDrawerOpen} from '#/state/shell'
import {emitSoftReset} from '#/state/events'
@@ -153,17 +153,19 @@ export function ProfileSubpageHeader({
<LoadingPlaceholder width={50} height={8} />
) : (
<Text type="xl" style={[pal.textLight]} numberOfLines={1}>
by{' '}
{!creator ? (
'—'
<Trans>by </Trans>
) : isOwner ? (
'you'
<Trans>by you</Trans>
) : (
<TextLink
text={sanitizeHandle(creator.handle, '@')}
href={makeProfileLink(creator)}
style={pal.textLight}
/>
<Trans>
by{' '}
<TextLink
text={sanitizeHandle(creator.handle, '@')}
href={makeProfileLink(creator)}
style={pal.textLight}
/>
</Trans>
)}
</Text>
)}
+1 -1
View File
@@ -22,7 +22,7 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) {
label: _(msg`Remove account`),
onPress: () => {
removeAccount(account)
Toast.show('Account removed from quick access')
Toast.show(_(msg`Account removed from quick access`))
},
icon: {
ios: {
+30
View File
@@ -0,0 +1,30 @@
import React from 'react'
import {StyleSheet, View, ViewProps} from 'react-native'
import {addStyle} from 'lib/styles'
type BlurViewProps = ViewProps & {
blurType?: 'dark' | 'light'
blurAmount?: number
}
export const BlurView = ({
style,
blurType,
...props
}: React.PropsWithChildren<BlurViewProps>) => {
if (blurType === 'dark') {
style = addStyle(style, styles.dark)
} else {
style = addStyle(style, styles.light)
}
return <View style={style} {...props} />
}
const styles = StyleSheet.create({
dark: {
backgroundColor: '#0008',
},
light: {
backgroundColor: '#fff8',
},
})
+4
View File
@@ -30,6 +30,7 @@ export function H1({children}: React.PropsWithChildren<{}>) {
const styles = useStyles()
const pal = usePalette('default')
const typography = useTheme().typography['title-xl']
// @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
return <ExpoH1 style={[typography, pal.text, styles.h1]}>{children}</ExpoH1>
}
@@ -37,6 +38,7 @@ export function H2({children}: React.PropsWithChildren<{}>) {
const styles = useStyles()
const pal = usePalette('default')
const typography = useTheme().typography['title-lg']
// @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
return <ExpoH2 style={[typography, pal.text, styles.h2]}>{children}</ExpoH2>
}
@@ -44,6 +46,7 @@ export function H3({children}: React.PropsWithChildren<{}>) {
const styles = useStyles()
const pal = usePalette('default')
const typography = useTheme().typography.title
// @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
return <ExpoH3 style={[typography, pal.text, styles.h3]}>{children}</ExpoH3>
}
@@ -51,6 +54,7 @@ export function H4({children}: React.PropsWithChildren<{}>) {
const styles = useStyles()
const pal = usePalette('default')
const typography = useTheme().typography['title-sm']
// @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf
return <ExpoH4 style={[typography, pal.text, styles.h4]}>{children}</ExpoH4>
}
+2 -14
View File
@@ -109,7 +109,7 @@ function ListImpl<ItemT>(
const containerRef = useRef(null)
useResizeObserver(containerRef, onContentSizeChange)
// --- onScroll & onScrollEndWeb ---
// --- onScroll ---
const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
const handleWindowScroll = useNonReactiveCallback(() => {
if (isInsideVisibleTree) {
@@ -124,16 +124,6 @@ function ListImpl<ItemT>(
)
}
})
const handleWindowScrollEnd = useNonReactiveCallback(() => {
if (isInsideVisibleTree) {
contextScrollHandlers.onScrollEndWeb?.({
contentOffset: {
x: Math.max(0, window.scrollX),
y: Math.max(0, window.scrollY),
},
})
}
})
React.useEffect(() => {
if (!isInsideVisibleTree) {
// Prevents hidden tabs from firing scroll events.
@@ -141,12 +131,10 @@ function ListImpl<ItemT>(
return
}
window.addEventListener('scroll', handleWindowScroll)
window.addEventListener('scrollend', handleWindowScrollEnd)
return () => {
window.removeEventListener('scroll', handleWindowScroll)
window.removeEventListener('scrollend', handleWindowScrollEnd)
}
}, [isInsideVisibleTree, handleWindowScroll, handleWindowScrollEnd])
}, [isInsideVisibleTree, handleWindowScroll])
// --- onScrolledDownChange ---
const isScrolledDown = useRef(false)
+50 -63
View File
@@ -4,9 +4,11 @@ import {ScrollProvider} from '#/lib/ScrollContext'
import {NativeScrollEvent} from 'react-native'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isWeb} from 'platform/detection'
import {isNative, isWeb} from 'platform/detection'
import {useSharedValue, interpolate} from 'react-native-reanimated'
const WEB_HIDE_SHELL_THRESHOLD = 200
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
@@ -28,26 +30,13 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
}
})
const snapToClosestMode = React.useCallback(
(scrollY: number) => {
startDragOffset.value = null
startMode.value = null
if (scrollY < headerHeight.value / 2) {
// If we're close to the top, show the shell.
setMode(false)
} else {
// Snap to whichever state is the closest.
setMode(Math.round(mode.value) === 1)
}
},
[startDragOffset, startMode, headerHeight, mode, setMode],
)
const onBeginDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
if (isNative) {
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
}
},
[mode, startDragOffset, startMode],
)
@@ -55,59 +44,58 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const onEndDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
snapToClosestMode(e.contentOffset.y)
if (isNative) {
startDragOffset.value = null
startMode.value = null
if (e.contentOffset.y < headerHeight.value / 2) {
// If we're close to the top, show the shell.
setMode(false)
} else {
// Snap to whichever state is the closest.
setMode(Math.round(mode.value) === 1)
}
}
},
[snapToClosestMode],
)
// On the web, we don't get the begin/end drag events,
// but we still need to expand or collapse the header.
const onScrollEndWeb = useCallback(
(e: Pick<NativeScrollEvent, 'contentOffset'>) => {
'worklet'
snapToClosestMode(e.contentOffset.y)
},
[snapToClosestMode],
[headerHeight, mode, setMode, startDragOffset, startMode],
)
const onScroll = useCallback(
(e: NativeScrollEvent) => {
'worklet'
if (startDragOffset.value === null || startMode.value === null) {
if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
// If we're close enough to the top, always show the shell.
// Even if we're not dragging.
setMode(false)
if (isNative) {
if (startDragOffset.value === null || startMode.value === null) {
if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
// If we're close enough to the top, always show the shell.
// Even if we're not dragging.
setMode(false)
}
return
}
if (isWeb) {
// On the web, there is no concept of "starting" the drag.
// When we get the first scroll event, we consider that the start.
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
}
return
}
// The "mode" value is always between 0 and 1.
// Figure out how much to move it based on the current dragged distance.
const dy = e.contentOffset.y - startDragOffset.value
const dProgress = interpolate(
dy,
[-headerHeight.value, headerHeight.value],
[-1, 1],
)
const newValue = clamp(startMode.value + dProgress, 0, 1)
if (newValue !== mode.value) {
// Manually adjust the value. This won't be (and shouldn't be) animated.
mode.value = newValue
}
if (isWeb) {
// On the web, there is no concept of "starting" the drag,
// so we don't have any specific anchor point to calculate the distance.
// Instead, update it continuosly along the way and diff with the last event.
// The "mode" value is always between 0 and 1.
// Figure out how much to move it based on the current dragged distance.
const dy = e.contentOffset.y - startDragOffset.value
const dProgress = interpolate(
dy,
[-headerHeight.value, headerHeight.value],
[-1, 1],
)
const newValue = clamp(startMode.value + dProgress, 0, 1)
if (newValue !== mode.value) {
// Manually adjust the value. This won't be (and shouldn't be) animated.
mode.value = newValue
}
} else {
// On the web, we don't try to follow the drag because we don't know when it ends.
// Instead, show/hide immediately based on whether we're scrolling up or down.
const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) {
setMode(false)
} else if (dy > 0) {
setMode(true)
}
}
},
[headerHeight, mode, setMode, startDragOffset, startMode],
@@ -117,8 +105,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
<ScrollProvider
onBeginDrag={onBeginDrag}
onEndDrag={onEndDrag}
onScroll={onScroll}
onScrollEndWeb={onScrollEndWeb}>
onScroll={onScroll}>
{children}
</ScrollProvider>
)
+3 -1
View File
@@ -11,6 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {isAndroid} from 'platform/detection'
import {TimeElapsed} from './TimeElapsed'
import {makeProfileLink} from 'lib/routes/links'
import {ModerationUI} from '@atproto/api'
interface PostMetaOpts {
author: {
@@ -23,6 +24,7 @@ interface PostMetaOpts {
postHref: string
timestamp: string
showAvatar?: boolean
avatarModeration?: ModerationUI
avatarSize?: number
displayNameType?: TypographyVariant
displayNameStyle?: StyleProp<TextStyle>
@@ -41,7 +43,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
<UserAvatar
avatar={opts.author.avatar}
size={opts.avatarSize || 16}
// TODO moderation
moderation={opts.avatarModeration}
/>
</View>
)}
+5 -2
View File
@@ -2,6 +2,8 @@ import React, {createRef, useState, useMemo, useRef} from 'react'
import {Animated, Pressable, StyleSheet, View} from 'react-native'
import {Text} from './text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
interface Layout {
x: number
@@ -19,6 +21,7 @@ export function Selector({
panX: Animated.Value
onSelect?: (index: number) => void
}) {
const {_} = useLingui()
const containerRef = useRef<View>(null)
const pal = usePalette('default')
const [itemLayouts, setItemLayouts] = useState<undefined | Layout[]>(
@@ -100,8 +103,8 @@ export function Selector({
testID={`selector-${i}`}
key={item}
onPress={() => onPressItem(i)}
accessibilityLabel={`Select ${item}`}
accessibilityHint={`Select option ${i} of ${numItems}`}>
accessibilityLabel={_(msg`Select ${item}`)}
accessibilityHint={_(msg`Select option ${i} of ${numItems}`)}>
<View style={styles.item} ref={itemRefs[i]}>
<Text
style={
+5 -2
View File
@@ -11,6 +11,8 @@ import {NavigationProp} from 'lib/routes/types'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import Animated from 'react-native-reanimated'
import {useSetDrawerOpen} from '#/state/shell'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
@@ -32,6 +34,7 @@ export function ViewHeader({
renderButton?: () => JSX.Element
}) {
const pal = usePalette('default')
const {_} = useLingui()
const setDrawerOpen = useSetDrawerOpen()
const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics()
@@ -75,9 +78,9 @@ export function ViewHeader({
hitSlop={BACK_HITSLOP}
style={canGoBack ? styles.backBtn : styles.backBtnWide}
accessibilityRole="button"
accessibilityLabel={canGoBack ? 'Back' : 'Menu'}
accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
accessibilityHint={
canGoBack ? '' : 'Access navigation links and settings'
canGoBack ? '' : _(msg`Access navigation links and settings`)
}>
{canGoBack ? (
<FontAwesomeIcon
+3 -1
View File
@@ -53,7 +53,9 @@ export function ErrorMessage({
onPress={onPressTryAgain}
accessibilityRole="button"
accessibilityLabel={_(msg`Retry`)}
accessibilityHint="Retries the last action, which errored out">
accessibilityHint={_(
msg`Retries the last action, which errored out`,
)}>
<FontAwesomeIcon
icon="arrows-rotate"
style={{color: theme.palette.error.icon}}
+4 -2
View File
@@ -63,14 +63,16 @@ export function ErrorScreen({
style={[styles.btn]}
onPress={onPressTryAgain}
accessibilityLabel={_(msg`Retry`)}
accessibilityHint="Retries the last action, which errored out">
accessibilityHint={_(
msg`Retries the last action, which errored out`,
)}>
<FontAwesomeIcon
icon="arrows-rotate"
style={pal.link as FontAwesomeIconStyle}
size={16}
/>
<Text type="button" style={[styles.btnText, pal.link]}>
<Trans>Try again</Trans>
<Trans context="action">Try again</Trans>
</Text>
</Button>
</View>
+12 -1
View File
@@ -13,6 +13,9 @@ import {Text} from '../text/Text'
import {TypographyVariant} from 'lib/ThemeContext'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {getLocales} from 'expo-localization'
const LOCALE = getLocales()[0]
interface Props {
testID?: string
@@ -25,6 +28,7 @@ interface Props {
accessibilityLabel: string
accessibilityHint: string
accessibilityLabelledBy?: string
handleAsUTC?: boolean
}
export function DateInput(props: Props) {
@@ -32,6 +36,12 @@ export function DateInput(props: Props) {
const theme = useTheme()
const pal = usePalette('default')
const formatter = React.useMemo(() => {
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: props.handleAsUTC ? 'UTC' : undefined,
})
}, [props.handleAsUTC])
const onChangeInternal = useCallback(
(event: DateTimePickerEvent, date: Date | undefined) => {
setShow(false)
@@ -64,7 +74,7 @@ export function DateInput(props: Props) {
<Text
type={props.buttonLabelType}
style={[pal.text, props.buttonLabelStyle]}>
{props.value.toLocaleDateString()}
{formatter.format(props.value)}
</Text>
</View>
</Button>
@@ -73,6 +83,7 @@ export function DateInput(props: Props) {
<DateTimePicker
testID={props.testID ? `${props.testID}-datepicker` : undefined}
mode="date"
timeZoneName={props.handleAsUTC ? 'Etc/UTC' : undefined}
display="spinner"
// @ts-ignore applies in iOS only -prf
themeVariant={theme.colorScheme}
+6 -2
View File
@@ -75,6 +75,8 @@ export function DropdownButton({
bottomOffset = 0,
accessibilityLabel,
}: PropsWithChildren<DropdownButtonProps>) {
const {_} = useLingui()
const ref1 = useRef<TouchableOpacity>(null)
const ref2 = useRef<View>(null)
@@ -141,7 +143,9 @@ export function DropdownButton({
hitSlop={HITSLOP_10}
ref={ref1}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel || `Opens ${numItems} options`}
accessibilityLabel={
accessibilityLabel || _(msg`Opens ${numItems} options`)
}
accessibilityHint="">
{children}
</TouchableOpacity>
@@ -247,7 +251,7 @@ const DropdownItems = ({
onPress={() => onPressItem(index)}
accessibilityRole="button"
accessibilityLabel={item.label}
accessibilityHint={`Option ${index + 1} of ${numItems}`}>
accessibilityHint={_(msg`Option ${index + 1} of ${numItems}`)}>
{item.icon && (
<FontAwesomeIcon
style={styles.icon}
@@ -0,0 +1,241 @@
import React from 'react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {Pressable, StyleSheet, View, Text} from 'react-native'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {HITSLOP_10} from 'lib/constants'
// Custom Dropdown Menu Components
// ==
export const DropdownMenuRoot = DropdownMenu.Root
export const DropdownMenuContent = DropdownMenu.Content
type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']>
export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
const theme = useTheme()
const [focused, setFocused] = React.useState(false)
const backgroundColor = theme.colorScheme === 'dark' ? '#fff1' : '#0001'
return (
<DropdownMenu.Item
{...props}
style={StyleSheet.flatten([
styles.item,
focused && {backgroundColor: backgroundColor},
])}
onFocus={() => {
setFocused(true)
}}
onBlur={() => {
setFocused(false)
}}
/>
)
}
// Types for Dropdown Menu and Items
export type DropdownItem = {
label: string | 'separator'
onPress?: () => void
testID?: string
icon?: {
ios: MenuItemCommonProps['ios']
android: string
web: IconProp
}
}
type Props = {
items: DropdownItem[]
testID?: string
accessibilityLabel?: string
accessibilityHint?: string
}
export function NativeDropdown({
items,
children,
testID,
accessibilityLabel,
accessibilityHint,
}: React.PropsWithChildren<Props>) {
const pal = usePalette('default')
const theme = useTheme()
const dropDownBackgroundColor =
theme.colorScheme === 'dark' ? pal.btn : pal.view
const [open, setOpen] = React.useState(false)
const buttonRef = React.useRef<HTMLButtonElement>(null)
const menuRef = React.useRef<HTMLDivElement>(null)
const {borderColor: separatorColor} =
theme.colorScheme === 'dark' ? pal.borderDark : pal.border
React.useEffect(() => {
function clickHandler(e: MouseEvent) {
const t = e.target
if (!open) return
if (!t) return
if (!buttonRef.current || !menuRef.current) return
if (
t !== buttonRef.current &&
!buttonRef.current.contains(t as Node) &&
t !== menuRef.current &&
!menuRef.current.contains(t as Node)
) {
// prevent clicking through to links beneath dropdown
// only applies to mobile web
e.preventDefault()
e.stopPropagation()
// close menu
setOpen(false)
}
}
function keydownHandler(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
setOpen(false)
}
}
document.addEventListener('click', clickHandler, true)
window.addEventListener('keydown', keydownHandler, true)
return () => {
document.removeEventListener('click', clickHandler, true)
window.removeEventListener('keydown', keydownHandler, true)
}
}, [open, setOpen])
return (
<DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}>
<DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}>
<Pressable
ref={buttonRef as unknown as React.Ref<View>}
testID={testID}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
onPress={() => setOpen(o => !o)}
hitSlop={HITSLOP_10}>
{children}
</Pressable>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
ref={menuRef}
style={
StyleSheet.flatten([
styles.content,
dropDownBackgroundColor,
]) as React.CSSProperties
}
loop>
{items.map((item, index) => {
if (item.label === 'separator') {
return (
<DropdownMenu.Separator
key={getKey(item.label, index, item.testID)}
style={
StyleSheet.flatten([
styles.separator,
{backgroundColor: separatorColor},
]) as React.CSSProperties
}
/>
)
}
if (index > 1 && items[index - 1].label === 'separator') {
return (
<DropdownMenu.Group
key={getKey(item.label, index, item.testID)}>
<DropdownMenuItem
key={getKey(item.label, index, item.testID)}
onSelect={item.onPress}>
<Text
selectable={false}
style={[pal.text, styles.itemTitle]}>
{item.label}
</Text>
{item.icon && (
<FontAwesomeIcon
icon={item.icon.web}
size={20}
color={pal.colors.textLight}
/>
)}
</DropdownMenuItem>
</DropdownMenu.Group>
)
}
return (
<DropdownMenuItem
key={getKey(item.label, index, item.testID)}
onSelect={item.onPress}>
<Text selectable={false} style={[pal.text, styles.itemTitle]}>
{item.label}
</Text>
{item.icon && (
<FontAwesomeIcon
icon={item.icon.web}
size={20}
color={pal.colors.textLight}
/>
)}
</DropdownMenuItem>
)
})}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenuRoot>
)
}
const getKey = (label: string, index: number, id?: string) => {
if (id) {
return id
}
return `${label}_${index}`
}
const styles = StyleSheet.create({
separator: {
height: 1,
marginTop: 4,
marginBottom: 4,
},
content: {
backgroundColor: '#f0f0f0',
borderRadius: 8,
paddingTop: 4,
paddingBottom: 4,
paddingLeft: 4,
paddingRight: 4,
marginTop: 6,
// @ts-ignore web only -prf
boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
},
item: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
columnGap: 20,
// @ts-ignore -web
cursor: 'pointer',
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 12,
paddingRight: 12,
borderRadius: 8,
},
itemTitle: {
fontSize: 16,
fontWeight: '500',
paddingRight: 10,
},
})
+52 -11
View File
@@ -2,7 +2,12 @@ import React, {memo} from 'react'
import {Linking, StyleProp, View, ViewStyle} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
import {
AppBskyActorDefs,
AppBskyFeedPost,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing'
@@ -18,11 +23,13 @@ import {getTranslatorLink} from '#/locale/helpers'
import {usePostDeleteMutation} from '#/state/queries/post'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
import {logger} from '#/logger'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
import {isWeb} from '#/platform/detection'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
let PostDropdownBtn = ({
testID,
@@ -30,6 +37,7 @@ let PostDropdownBtn = ({
postCid,
postUri,
record,
richText,
style,
showAppealLabelItem,
}: {
@@ -38,6 +46,7 @@ let PostDropdownBtn = ({
postCid: string
postUri: string
record: AppBskyFeedPost.Record
richText: RichTextAPI
style?: StyleProp<ViewStyle>
showAppealLabelItem?: boolean
}): React.ReactNode => {
@@ -50,9 +59,12 @@ let PostDropdownBtn = ({
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation()
const hiddenPosts = useHiddenPosts()
const {hidePost} = useHiddenPostsApi()
const rootUri = record.reply?.root?.uri || postUri
const isThreadMuted = mutedThreads.includes(rootUri)
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did
const href = React.useMemo(() => {
const urip = new AtUri(postUri)
@@ -67,37 +79,45 @@ let PostDropdownBtn = ({
const onDeletePost = React.useCallback(() => {
postDeleteMutation.mutateAsync({uri: postUri}).then(
() => {
Toast.show('Post deleted')
Toast.show(_(msg`Post deleted`))
},
e => {
logger.error('Failed to delete post', {error: e})
Toast.show('Failed to delete post, please try again')
Toast.show(_(msg`Failed to delete post, please try again`))
},
)
}, [postUri, postDeleteMutation])
}, [postUri, postDeleteMutation, _])
const onToggleThreadMute = React.useCallback(() => {
try {
const muted = toggleThreadMute(rootUri)
if (muted) {
Toast.show('You will no longer receive notifications for this thread')
Toast.show(
_(msg`You will no longer receive notifications for this thread`),
)
} else {
Toast.show('You will now receive notifications for this thread')
Toast.show(_(msg`You will now receive notifications for this thread`))
}
} catch (e) {
logger.error('Failed to toggle thread mute', {error: e})
}
}, [rootUri, toggleThreadMute])
}, [rootUri, toggleThreadMute, _])
const onCopyPostText = React.useCallback(() => {
Clipboard.setString(record?.text || '')
Toast.show('Copied to clipboard')
}, [record])
const str = richTextToString(richText)
Clipboard.setString(str)
Toast.show(_(msg`Copied to clipboard`))
}, [_, richText])
const onOpenTranslate = React.useCallback(() => {
Linking.openURL(translatorUrl)
}, [translatorUrl])
const onHidePost = React.useCallback(() => {
hidePost({uri: postUri})
}, [postUri, hidePost])
const dropdownItems: NativeDropdownItem[] = [
{
label: _(msg`Translate`),
@@ -159,6 +179,27 @@ let PostDropdownBtn = ({
web: 'comment-slash',
},
},
hasSession &&
!isAuthor &&
!isPostHidden && {
label: _(msg`Hide post`),
onPress() {
openModal({
name: 'confirm',
title: _(msg`Hide this post?`),
message: _(msg`This will hide this post from your feeds.`),
onPressConfirm: onHidePost,
})
},
testID: 'postDropdownHideBtn',
icon: {
ios: {
name: 'eye.slash',
},
android: 'ic_menu_delete',
web: ['far', 'eye-slash'],
},
},
{
label: 'separator',
},
@@ -224,7 +265,7 @@ let PostDropdownBtn = ({
<NativeDropdown
testID={testID}
items={dropdownItems}
accessibilityLabel="More post options"
accessibilityLabel={_(msg`More post options`)}
accessibilityHint="">
<View style={style}>
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
+4 -2
View File
@@ -11,6 +11,7 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {HITSLOP_10} from 'lib/constants'
import {MagnifyingGlassIcon} from 'lib/icons'
import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
@@ -49,7 +50,7 @@ export function SearchInput({
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder="Search"
placeholder={_(msg`Search`)}
placeholderTextColor={pal.colors.textLight}
selectTextOnFocus
returnKeyType="search"
@@ -71,7 +72,8 @@ export function SearchInput({
onPress={onPressCancelSearchInner}
accessibilityRole="button"
accessibilityLabel={_(msg`Clear search query`)}
accessibilityHint="">
accessibilityHint=""
hitSlop={HITSLOP_10}>
<FontAwesomeIcon
icon="xmark"
size={16}

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