Merge branch 'main' into web-layout
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>{' '}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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=""
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
_,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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'}
|
||||
|
||||
@@ -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]}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]}
|
||||
/>
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user