Merge remote-tracking branch 'origin/main' into samuel/alf-login
This commit is contained in:
commit
4794ab6b9a
83 changed files with 4447 additions and 4712 deletions
|
@ -52,7 +52,9 @@ export function HomeLoggedOutCTA() {
|
|||
onPress={showCreateAccount}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Create new account`)}
|
||||
accessibilityHint="Opens flow to create a new Bluesky account">
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to create a new Bluesky account`,
|
||||
)}>
|
||||
<Text
|
||||
style={[
|
||||
s.white,
|
||||
|
@ -68,7 +70,9 @@ export function HomeLoggedOutCTA() {
|
|||
onPress={showSignIn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Sign in`)}
|
||||
accessibilityHint="Opens flow to sign into your existing Bluesky account">
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to sign into your existing Bluesky account`,
|
||||
)}>
|
||||
<Text
|
||||
style={[
|
||||
pal.text,
|
||||
|
|
|
@ -66,7 +66,9 @@ export const SplashScreen = ({
|
|||
onPress={onPressCreateAccount}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Create new account`)}
|
||||
accessibilityHint="Opens flow to create a new Bluesky account">
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to create a new Bluesky account`,
|
||||
)}>
|
||||
<Text style={[s.white, styles.btnLabel]}>
|
||||
<Trans>Create a new account</Trans>
|
||||
</Text>
|
||||
|
@ -77,7 +79,9 @@ export const SplashScreen = ({
|
|||
onPress={onPressSignin}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Sign in`)}
|
||||
accessibilityHint="Opens flow to sign into your existing Bluesky account">
|
||||
accessibilityHint={_(
|
||||
msg`Opens flow to sign into your existing Bluesky account`,
|
||||
)}>
|
||||
<Text style={[pal.text, styles.btnLabel]}>
|
||||
<Trans>Sign In</Trans>
|
||||
</Text>
|
||||
|
|
|
@ -9,6 +9,8 @@ import {TextLink} from '../../util/Link'
|
|||
import {Text} from '../../util/text/Text'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
|
||||
|
||||
|
@ -22,6 +24,7 @@ export const Policies = ({
|
|||
under13: boolean
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
if (!serviceDescription) {
|
||||
return <View />
|
||||
}
|
||||
|
@ -42,7 +45,9 @@ export const Policies = ({
|
|||
/>
|
||||
</View>
|
||||
<Text style={[pal.textLight, s.pl5, s.flex1]}>
|
||||
This service has not provided terms of service or a privacy policy.
|
||||
<Trans>
|
||||
This service has not provided terms of service or a privacy policy.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
@ -53,7 +58,7 @@ export const Policies = ({
|
|||
<TextLink
|
||||
key="tos"
|
||||
href={tos}
|
||||
text="Terms of Service"
|
||||
text={_(msg`Terms of Service`)}
|
||||
style={[pal.link, s.underline]}
|
||||
onPress={() => Linking.openURL(tos)}
|
||||
/>,
|
||||
|
@ -64,7 +69,7 @@ export const Policies = ({
|
|||
<TextLink
|
||||
key="pp"
|
||||
href={pp}
|
||||
text="Privacy Policy"
|
||||
text={_(msg`Privacy Policy`)}
|
||||
style={[pal.link, s.underline]}
|
||||
onPress={() => Linking.openURL(pp)}
|
||||
/>,
|
||||
|
@ -83,7 +88,7 @@ export const Policies = ({
|
|||
return (
|
||||
<View style={styles.policies}>
|
||||
<Text style={pal.textLight}>
|
||||
By creating an account you agree to the {els}.
|
||||
<Trans>By creating an account you agree to the {els}.</Trans>
|
||||
</Text>
|
||||
{under13 ? (
|
||||
<Text style={[pal.textLight, s.bold]}>
|
||||
|
@ -91,8 +96,10 @@ export const Policies = ({
|
|||
</Text>
|
||||
) : needsGuardian ? (
|
||||
<Text style={[pal.textLight, s.bold]}>
|
||||
If you are not yet an adult according to the laws of your country,
|
||||
your parent or legal guardian must read these Terms on your behalf.
|
||||
<Trans>
|
||||
If you are not yet an adult according to the laws of your country,
|
||||
your parent or legal guardian must read these Terms on your behalf.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
|
|
|
@ -11,7 +11,8 @@ import {Text} from 'view/com/util/text/Text'
|
|||
import Animated, {FadeInRight} from 'react-native-reanimated'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {logger} from '#/logger'
|
||||
|
@ -70,6 +71,7 @@ function ProfileCard({
|
|||
}) {
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const [addingMoreSuggestions, setAddingMoreSuggestions] =
|
||||
React.useState(false)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
|
@ -136,7 +138,7 @@ function ProfileCard({
|
|||
type={profile.viewer?.following ? 'default' : 'inverted'}
|
||||
labelStyle={styles.followButton}
|
||||
onPress={onToggleFollow}
|
||||
label={profile.viewer?.following ? 'Unfollow' : 'Follow'}
|
||||
label={profile.viewer?.following ? _(msg`Unfollow`) : _(msg`Follow`)}
|
||||
/>
|
||||
</View>
|
||||
{profile.description ? (
|
||||
|
|
|
@ -6,7 +6,8 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {Button} from 'view/com/util/forms/Button'
|
||||
import {ViewHeader} from 'view/com/util/ViewHeader'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
|
||||
type Props = {
|
||||
next: () => void
|
||||
|
@ -15,6 +16,7 @@ type Props = {
|
|||
|
||||
export function WelcomeMobile({next, skip}: Props) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[styles.container]} testID="welcomeOnboarding">
|
||||
|
@ -91,7 +93,7 @@ export function WelcomeMobile({next, skip}: Props) {
|
|||
|
||||
<Button
|
||||
onPress={next}
|
||||
label="Continue"
|
||||
label={_(msg`Continue`)}
|
||||
testID="continueBtn"
|
||||
style={[styles.buttonContainer]}
|
||||
labelStyle={styles.buttonText}
|
||||
|
|
|
@ -115,7 +115,7 @@ export function ServerInputDialog({
|
|||
testID="customServerTextInput"
|
||||
value={customAddress}
|
||||
onChangeText={setCustomAddress}
|
||||
label={_(msg`my-server.com`)}
|
||||
label="my-server.com"
|
||||
accessibilityLabelledBy="address-input-label"
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
|
|
|
@ -415,7 +415,11 @@ export const ComposePost = observer(function ComposePost({
|
|||
styles.textInputLayout,
|
||||
isNative && styles.textInputLayoutMobile,
|
||||
]}>
|
||||
<UserAvatar avatar={currentProfile?.avatar} size={50} />
|
||||
<UserAvatar
|
||||
avatar={currentProfile?.avatar}
|
||||
size={50}
|
||||
type={currentProfile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<TextInput
|
||||
ref={textInput}
|
||||
richtext={richtext}
|
||||
|
|
|
@ -87,6 +87,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
|||
avatar={replyTo.author.avatar}
|
||||
size={50}
|
||||
moderation={replyTo.moderation?.ui('avatar')}
|
||||
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={styles.replyToPost}>
|
||||
<Text type="xl-medium" style={[pal.text]}>
|
||||
|
|
|
@ -23,7 +23,11 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
|
|||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Compose reply`)}
|
||||
accessibilityHint={_(msg`Opens composer`)}>
|
||||
<UserAvatar avatar={profile?.avatar} size={38} />
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={38}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<Text
|
||||
type="xl"
|
||||
style={[
|
||||
|
|
|
@ -78,7 +78,11 @@ export function Autocomplete({
|
|||
accessibilityLabel={`Select ${item.handle}`}
|
||||
accessibilityHint="">
|
||||
<View style={styles.avatarAndHandle}>
|
||||
<UserAvatar avatar={item.avatar ?? null} size={24} />
|
||||
<UserAvatar
|
||||
avatar={item.avatar ?? null}
|
||||
size={24}
|
||||
type={item.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<Text type="md-medium" style={pal.text}>
|
||||
{displayName}
|
||||
</Text>
|
||||
|
|
|
@ -175,7 +175,11 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
|
|||
}}
|
||||
accessibilityRole="button">
|
||||
<View style={styles.avatarAndDisplayName}>
|
||||
<UserAvatar avatar={item.avatar ?? null} size={26} />
|
||||
<UserAvatar
|
||||
avatar={item.avatar ?? null}
|
||||
size={26}
|
||||
type={item.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<Text style={pal.text} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
|
|
|
@ -78,9 +78,9 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
|
|||
|
||||
try {
|
||||
await saveImageToMediaLibrary({uri})
|
||||
Toast.show('Saved to your camera roll.')
|
||||
Toast.show(_(msg`Saved to your camera roll.`))
|
||||
} catch (e: any) {
|
||||
Toast.show(`Failed to save image: ${String(e)}`)
|
||||
Toast.show(_(msg`Failed to save image: ${String(e)}`))
|
||||
}
|
||||
},
|
||||
[permissionResponse, requestPermission, _],
|
||||
|
|
|
@ -150,7 +150,7 @@ export function Inner({
|
|||
accessibilityHint={_(msg`Exits handle change process`)}
|
||||
onAccessibilityEscape={onPressCancel}>
|
||||
<Text type="lg" style={pal.textLight}>
|
||||
Cancel
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
@ -254,7 +254,7 @@ function ProvidedHandleForm({
|
|||
<TextInput
|
||||
testID="setHandleInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="e.g. alice"
|
||||
placeholder={_(msg`e.g. alice`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
|
@ -277,8 +277,8 @@ function ProvidedHandleForm({
|
|||
<TouchableOpacity
|
||||
onPress={onToggleCustom}
|
||||
accessibilityRole="button"
|
||||
accessibilityHint="Hosting provider"
|
||||
accessibilityLabel={_(msg`Opens modal for using custom domain`)}>
|
||||
accessibilityLabel={_(msg`Hosting provider`)}
|
||||
accessibilityHint={_(msg`Opens modal for using custom domain`)}>
|
||||
<Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
|
||||
<Trans>I have my own domain</Trans>
|
||||
</Text>
|
||||
|
@ -324,8 +324,8 @@ function CustomHandleForm({
|
|||
Clipboard.setString(
|
||||
isDNSForm ? `did=${currentAccount.did}` : currentAccount.did,
|
||||
)
|
||||
Toast.show('Copied to clipboard')
|
||||
}, [currentAccount, isDNSForm])
|
||||
Toast.show(_(msg`Copied to clipboard`))
|
||||
}, [currentAccount, isDNSForm, _])
|
||||
const onChangeHandle = React.useCallback(
|
||||
(v: string) => {
|
||||
setHandle(v)
|
||||
|
@ -378,7 +378,7 @@ function CustomHandleForm({
|
|||
<TextInput
|
||||
testID="setHandleInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="e.g. alice.com"
|
||||
placeholder={_(msg`e.g. alice.com`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
autoCapitalize="none"
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
|
@ -387,7 +387,7 @@ function CustomHandleForm({
|
|||
editable={!isProcessing}
|
||||
accessibilityLabelledBy="customDomain"
|
||||
accessibilityLabel={_(msg`Custom domain`)}
|
||||
accessibilityHint="Input your preferred hosting provider"
|
||||
accessibilityHint={_(msg`Input your preferred hosting provider`)}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.spacer} />
|
||||
|
@ -395,18 +395,18 @@ function CustomHandleForm({
|
|||
<View style={[styles.selectableBtns]}>
|
||||
<SelectableBtn
|
||||
selected={isDNSForm}
|
||||
label="DNS Panel"
|
||||
label={_(msg`DNS Panel`)}
|
||||
left
|
||||
onSelect={() => setDNSForm(true)}
|
||||
accessibilityHint="Use the DNS panel"
|
||||
accessibilityHint={_(msg`Use the DNS panel`)}
|
||||
style={s.flex1}
|
||||
/>
|
||||
<SelectableBtn
|
||||
selected={!isDNSForm}
|
||||
label="No DNS Panel"
|
||||
label={_(msg`No DNS Panel`)}
|
||||
right
|
||||
onSelect={() => setDNSForm(false)}
|
||||
accessibilityHint="Use a file on your server"
|
||||
accessibilityHint={_(msg`Use a file on your server`)}
|
||||
style={s.flex1}
|
||||
/>
|
||||
</View>
|
||||
|
@ -418,7 +418,7 @@ function CustomHandleForm({
|
|||
</Text>
|
||||
<View style={[styles.dnsTable, pal.btn]}>
|
||||
<Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
|
||||
Host:
|
||||
<Trans>Host:</Trans>
|
||||
</Text>
|
||||
<View style={[styles.dnsValue]}>
|
||||
<Text type="mono" style={[styles.monoText, pal.text]}>
|
||||
|
@ -426,7 +426,7 @@ function CustomHandleForm({
|
|||
</Text>
|
||||
</View>
|
||||
<Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
|
||||
Type:
|
||||
<Trans>Type:</Trans>
|
||||
</Text>
|
||||
<View style={[styles.dnsValue]}>
|
||||
<Text type="mono" style={[styles.monoText, pal.text]}>
|
||||
|
@ -434,7 +434,7 @@ function CustomHandleForm({
|
|||
</Text>
|
||||
</View>
|
||||
<Text type="md-medium" style={[styles.dnsLabel, pal.text]}>
|
||||
Value:
|
||||
<Trans>Value:</Trans>
|
||||
</Text>
|
||||
<View style={[styles.dnsValue]}>
|
||||
<Text type="mono" style={[styles.monoText, pal.text]}>
|
||||
|
@ -443,7 +443,7 @@ function CustomHandleForm({
|
|||
</View>
|
||||
</View>
|
||||
<Text type="md" style={[pal.text, s.pt20, s.pl5]}>
|
||||
This should create a domain record at:{' '}
|
||||
<Trans>This should create a domain record at:</Trans>
|
||||
</Text>
|
||||
<Text type="mono" style={[styles.monoText, pal.text, s.pt5, s.pl5]}>
|
||||
_atproto.{handle}
|
||||
|
@ -463,7 +463,7 @@ function CustomHandleForm({
|
|||
</View>
|
||||
<View style={styles.spacer} />
|
||||
<Text type="md" style={[pal.text, s.pb5, s.pl5]}>
|
||||
That contains the following:
|
||||
<Trans>That contains the following:</Trans>
|
||||
</Text>
|
||||
<View style={[styles.valueContainer, pal.btn]}>
|
||||
<View style={[styles.dnsValue]}>
|
||||
|
@ -478,7 +478,9 @@ function CustomHandleForm({
|
|||
<View style={styles.spacer} />
|
||||
<Button type="default" style={[s.p20, s.mb10]} onPress={onPressCopy}>
|
||||
<Text type="xl" style={[pal.link, s.textCenter]}>
|
||||
Copy {isDNSForm ? 'Domain Value' : 'File Contents'}
|
||||
<Trans>
|
||||
Copy {isDNSForm ? _(msg`Domain Value`) : _(msg`File Contents`)}
|
||||
</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
{canSave === true && (
|
||||
|
@ -504,8 +506,8 @@ function CustomHandleForm({
|
|||
) : (
|
||||
<Text type="xl-medium" style={[s.white, s.textCenter]}>
|
||||
{canSave
|
||||
? `Update to ${handle}`
|
||||
: `Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`}
|
||||
? _(msg`Update to ${handle}`)
|
||||
: _(msg`Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`)}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
@ -513,9 +515,9 @@ function CustomHandleForm({
|
|||
<TouchableOpacity
|
||||
onPress={onToggleCustom}
|
||||
accessibilityLabel={_(msg`Use default provider`)}
|
||||
accessibilityHint="Use bsky.social as hosting provider">
|
||||
accessibilityHint={_(msg`Use bsky.social as hosting provider`)}>
|
||||
<Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}>
|
||||
Nevermind, create a handle for me
|
||||
<Trans>Nevermind, create a handle for me</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
|
|
|
@ -137,7 +137,9 @@ export function Component() {
|
|||
<View>
|
||||
<View style={styles.titleSection}>
|
||||
<Text type="title-lg" style={[pal.text, styles.title]}>
|
||||
{stage !== Stages.Done ? 'Change Password' : 'Password Changed'}
|
||||
{stage !== Stages.Done
|
||||
? _(msg`Change Password`)
|
||||
: _(msg`Password Changed`)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
@ -180,7 +182,7 @@ export function Component() {
|
|||
<TextInput
|
||||
testID="codeInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="Reset code"
|
||||
placeholder={_(msg`Reset code`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
value={resetCode}
|
||||
onChangeText={setResetCode}
|
||||
|
@ -207,7 +209,7 @@ export function Component() {
|
|||
<TextInput
|
||||
testID="codeInput"
|
||||
style={[pal.text, styles.textInput]}
|
||||
placeholder="New password"
|
||||
placeholder={_(msg`New password`)}
|
||||
placeholderTextColor={pal.colors.textLight}
|
||||
onChangeText={setNewPassword}
|
||||
secureTextEntry
|
||||
|
|
|
@ -173,7 +173,7 @@ export function Component({}: {}) {
|
|||
</Text>
|
||||
<TextInput
|
||||
style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]}
|
||||
placeholder="Confirmation code"
|
||||
placeholder={_(msg`Confirmation code`)}
|
||||
placeholderTextColor={pal.textLight.color}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
value={confirmCode}
|
||||
|
@ -192,7 +192,7 @@ export function Component({}: {}) {
|
|||
</Text>
|
||||
<TextInput
|
||||
style={[styles.textInput, pal.borderDark, pal.text]}
|
||||
placeholder="Password"
|
||||
placeholder={_(msg`Password`)}
|
||||
placeholderTextColor={pal.textLight.color}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
secureTextEntry
|
||||
|
@ -228,7 +228,7 @@ export function Component({}: {}) {
|
|||
onPress={onCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Cancel account deletion`)}
|
||||
accessibilityHint="Exits account deletion process"
|
||||
accessibilityHint={_(msg`Exits account deletion process`)}
|
||||
onAccessibilityEscape={onCancel}>
|
||||
<Text type="button-lg" style={pal.textLight}>
|
||||
<Trans context="action">Cancel</Trans>
|
||||
|
|
|
@ -77,7 +77,7 @@ export function Component({href}: {href: string}) {
|
|||
}}
|
||||
accessibilityLabel={_(msg`Cancel`)}
|
||||
accessibilityHint=""
|
||||
label="Cancel"
|
||||
label={_(msg`Cancel`)}
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 8}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
|
|
|
@ -73,8 +73,8 @@ export function Component({text, href}: {text: string; href: string}) {
|
|||
type="primary"
|
||||
onPress={onPressVisit}
|
||||
accessibilityLabel={_(msg`Visit Site`)}
|
||||
accessibilityHint=""
|
||||
label="Visit Site"
|
||||
accessibilityHint={_(msg`Opens the linked website`)}
|
||||
label={_(msg`Visit Site`)}
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
|
@ -85,8 +85,8 @@ export function Component({text, href}: {text: string; href: string}) {
|
|||
closeModal()
|
||||
}}
|
||||
accessibilityLabel={_(msg`Cancel`)}
|
||||
accessibilityHint=""
|
||||
label="Cancel"
|
||||
accessibilityHint={_(msg`Cancels opening the linked website`)}
|
||||
label={_(msg`Cancel`)}
|
||||
labelContainerStyle={{justifyContent: 'center', padding: 4}}
|
||||
labelStyle={[s.f18]}
|
||||
/>
|
||||
|
|
|
@ -231,7 +231,11 @@ function UserResult({
|
|||
width: 54,
|
||||
paddingLeft: 4,
|
||||
}}>
|
||||
<UserAvatar size={40} avatar={profile.avatar} />
|
||||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile.avatar}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
|
|
|
@ -45,7 +45,11 @@ function SwitchAccountCard({account}: {account: SessionAccount}) {
|
|||
const contents = (
|
||||
<View style={[pal.view, styles.linkCard]}>
|
||||
<View style={styles.avi}>
|
||||
<UserAvatar size={40} avatar={profile?.avatar} />
|
||||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile?.avatar}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<View style={[s.flex1]}>
|
||||
<Text type="md-bold" style={pal.text} numberOfLines={1}>
|
||||
|
|
|
@ -180,7 +180,7 @@ function ListItem({
|
|||
},
|
||||
]}>
|
||||
<View style={styles.listItemAvi}>
|
||||
<UserAvatar size={40} avatar={list.avatar} />
|
||||
<UserAvatar size={40} avatar={list.avatar} type="list" />
|
||||
</View>
|
||||
<View style={styles.listItemContent}>
|
||||
<Text
|
||||
|
|
|
@ -149,7 +149,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
|
|||
onPress={onEmailIncorrect}
|
||||
style={styles.changeEmailLink}>
|
||||
<Text type="lg" style={pal.link}>
|
||||
Change
|
||||
<Trans>Change</Trans>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
|
|
|
@ -100,7 +100,7 @@ export function Component({
|
|||
onPress={doSetAs(AspectRatio.Wide)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Wide`)}
|
||||
accessibilityHint="Sets image aspect ratio to wide">
|
||||
accessibilityHint={_(msg`Sets image aspect ratio to wide`)}>
|
||||
<RectWideIcon
|
||||
size={24}
|
||||
style={as === AspectRatio.Wide ? s.blue3 : pal.text}
|
||||
|
@ -110,7 +110,7 @@ export function Component({
|
|||
onPress={doSetAs(AspectRatio.Tall)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Tall`)}
|
||||
accessibilityHint="Sets image aspect ratio to tall">
|
||||
accessibilityHint={_(msg`Sets image aspect ratio to tall`)}>
|
||||
<RectTallIcon
|
||||
size={24}
|
||||
style={as === AspectRatio.Tall ? s.blue3 : pal.text}
|
||||
|
@ -120,7 +120,7 @@ export function Component({
|
|||
onPress={doSetAs(AspectRatio.Square)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Square`)}
|
||||
accessibilityHint="Sets image aspect ratio to square">
|
||||
accessibilityHint={_(msg`Sets image aspect ratio to square`)}>
|
||||
<SquareIcon
|
||||
size={24}
|
||||
style={as === AspectRatio.Square ? s.blue3 : pal.text}
|
||||
|
@ -132,9 +132,9 @@ export function Component({
|
|||
onPress={onPressCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Cancel image crop`)}
|
||||
accessibilityHint="Exits image cropping process">
|
||||
accessibilityHint={_(msg`Exits image cropping process`)}>
|
||||
<Text type="xl" style={pal.link}>
|
||||
Cancel
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.flex1} />
|
||||
|
@ -142,7 +142,7 @@ export function Component({
|
|||
onPress={onPressDone}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Save image crop`)}
|
||||
accessibilityHint="Saves image crop settings">
|
||||
accessibilityHint={_(msg`Saves image crop settings`)}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
ModerationDecision,
|
||||
moderateProfile,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyActorDefs,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {
|
||||
|
@ -55,6 +56,7 @@ interface Author {
|
|||
displayName?: string
|
||||
avatar?: string
|
||||
moderation: ModerationDecision
|
||||
associated?: AppBskyActorDefs.ProfileAssociated
|
||||
}
|
||||
|
||||
let FeedItem = ({
|
||||
|
@ -100,6 +102,7 @@ let FeedItem = ({
|
|||
displayName: item.notification.author.displayName,
|
||||
avatar: item.notification.author.avatar,
|
||||
moderation: moderateProfile(item.notification.author, moderationOpts),
|
||||
associated: item.notification.author.associated,
|
||||
},
|
||||
...(item.additional?.map(({author}) => {
|
||||
return {
|
||||
|
@ -109,6 +112,7 @@ let FeedItem = ({
|
|||
displayName: author.displayName,
|
||||
avatar: author.avatar,
|
||||
moderation: moderateProfile(author, moderationOpts),
|
||||
associated: author.associated,
|
||||
}
|
||||
}) || []),
|
||||
]
|
||||
|
@ -337,6 +341,7 @@ function CondensedAuthorsList({
|
|||
handle={authors[0].handle}
|
||||
avatar={authors[0].avatar}
|
||||
moderation={authors[0].moderation.ui('avatar')}
|
||||
type={authors[0].associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
@ -355,6 +360,7 @@ function CondensedAuthorsList({
|
|||
size={35}
|
||||
avatar={author.avatar}
|
||||
moderation={author.moderation.ui('avatar')}
|
||||
type={author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
@ -413,6 +419,7 @@ function ExpandedAuthorsList({
|
|||
size={35}
|
||||
avatar={author.avatar}
|
||||
moderation={author.moderation.ui('avatar')}
|
||||
type={author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<View style={s.flex1}>
|
||||
|
|
|
@ -1,25 +1,14 @@
|
|||
import React, {useEffect, useRef} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {LoadingScreen} from '../util/LoadingScreen'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {List, ListMethods} from '../util/List'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {PostThreadItem} from './PostThreadItem'
|
||||
import {ComposePrompt} from '../composer/Prompt'
|
||||
import {ViewHeader} from '../util/ViewHeader'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {
|
||||
|
@ -30,21 +19,18 @@ import {
|
|||
usePostThreadQuery,
|
||||
sortThread,
|
||||
} from '#/state/queries/post-thread'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {
|
||||
UsePreferencesQueryResponse,
|
||||
useModerationOpts,
|
||||
usePreferencesQuery,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
// FlatList maintainVisibleContentPosition breaks if too many items
|
||||
// are prepended. This seems to be an optimal number based on *shrug*.
|
||||
|
@ -58,9 +44,7 @@ const MAINTAIN_VISIBLE_CONTENT_POSITION = {
|
|||
|
||||
const TOP_COMPONENT = {_reactKey: '__top_component__'}
|
||||
const REPLY_PROMPT = {_reactKey: '__reply__'}
|
||||
const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
|
||||
const LOAD_MORE = {_reactKey: '__load_more__'}
|
||||
const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
|
||||
|
||||
type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound
|
||||
type RowItem =
|
||||
|
@ -68,9 +52,7 @@ type RowItem =
|
|||
// TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
|
||||
| typeof TOP_COMPONENT
|
||||
| typeof REPLY_PROMPT
|
||||
| typeof CHILD_SPINNER
|
||||
| typeof LOAD_MORE
|
||||
| typeof BOTTOM_COMPONENT
|
||||
|
||||
type ThreadSkeletonParts = {
|
||||
parents: YieldedItem[]
|
||||
|
@ -78,6 +60,10 @@ type ThreadSkeletonParts = {
|
|||
replies: YieldedItem[]
|
||||
}
|
||||
|
||||
const keyExtractor = (item: RowItem) => {
|
||||
return item._reactKey
|
||||
}
|
||||
|
||||
export function PostThread({
|
||||
uri,
|
||||
onCanReply,
|
||||
|
@ -85,17 +71,30 @@ export function PostThread({
|
|||
}: {
|
||||
uri: string | undefined
|
||||
onCanReply: (canReply: boolean) => void
|
||||
onPressReply: () => void
|
||||
onPressReply: () => unknown
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
isFetching,
|
||||
isError: isThreadError,
|
||||
error: threadError,
|
||||
refetch,
|
||||
data: thread,
|
||||
} = usePostThreadQuery(uri)
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
|
||||
const treeView = React.useMemo(
|
||||
() =>
|
||||
!!preferences?.threadViewPrefs?.lab_treeViewEnabled &&
|
||||
hasBranchingReplies(thread),
|
||||
[preferences?.threadViewPrefs, thread],
|
||||
)
|
||||
const rootPost = thread?.type === 'post' ? thread.post : undefined
|
||||
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
|
||||
|
||||
|
@ -105,7 +104,6 @@ export function PostThread({
|
|||
rootPost && moderationOpts
|
||||
? moderatePost(rootPost, moderationOpts)
|
||||
: undefined
|
||||
|
||||
return !!mod
|
||||
?.ui('contentList')
|
||||
.blurs.find(
|
||||
|
@ -114,6 +112,14 @@ export function PostThread({
|
|||
)
|
||||
}, [rootPost, moderationOpts])
|
||||
|
||||
// Values used for proper rendering of parents
|
||||
const ref = useRef<ListMethods>(null)
|
||||
const highlightedPostRef = useRef<View | null>(null)
|
||||
const [maxParents, setMaxParents] = React.useState(
|
||||
isWeb ? Infinity : PARENTS_CHUNK_SIZE,
|
||||
)
|
||||
const [maxReplies, setMaxReplies] = React.useState(50)
|
||||
|
||||
useSetTitle(
|
||||
rootPost && !isNoPwi
|
||||
? `${sanitizeDisplayName(
|
||||
|
@ -121,62 +127,6 @@ export function PostThread({
|
|||
)}: "${rootPostRecord!.text}"`
|
||||
: '',
|
||||
)
|
||||
useEffect(() => {
|
||||
if (rootPost) {
|
||||
onCanReply(!rootPost.viewer?.replyDisabled)
|
||||
}
|
||||
}, [rootPost, onCanReply])
|
||||
|
||||
if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
|
||||
return (
|
||||
<PostThreadError
|
||||
error={error}
|
||||
notFound={AppBskyFeedDefs.isNotFoundPost(thread)}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (AppBskyFeedDefs.isBlockedPost(thread)) {
|
||||
return <PostThreadBlocked />
|
||||
}
|
||||
if (!thread || isLoading || !preferences) {
|
||||
return <LoadingScreen />
|
||||
}
|
||||
return (
|
||||
<PostThreadLoaded
|
||||
thread={thread}
|
||||
threadViewPrefs={preferences.threadViewPrefs}
|
||||
onRefresh={refetch}
|
||||
onPressReply={onPressReply}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PostThreadLoaded({
|
||||
thread,
|
||||
threadViewPrefs,
|
||||
onRefresh,
|
||||
onPressReply,
|
||||
}: {
|
||||
thread: ThreadNode
|
||||
threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
|
||||
onRefresh: () => void
|
||||
onPressReply: () => void
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
|
||||
const ref = useRef<ListMethods>(null)
|
||||
const highlightedPostRef = useRef<View | null>(null)
|
||||
const [maxParents, setMaxParents] = React.useState(
|
||||
isWeb ? Infinity : PARENTS_CHUNK_SIZE,
|
||||
)
|
||||
const [maxReplies, setMaxReplies] = React.useState(100)
|
||||
const treeView = React.useMemo(
|
||||
() => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread),
|
||||
[threadViewPrefs, thread],
|
||||
)
|
||||
|
||||
// On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
|
||||
// This ensures that the first render contains no parents--even if they are already available in the cache.
|
||||
|
@ -184,18 +134,56 @@ function PostThreadLoaded({
|
|||
// On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
|
||||
const [deferParents, setDeferParents] = React.useState(isNative)
|
||||
|
||||
const skeleton = React.useMemo(
|
||||
() =>
|
||||
createThreadSkeleton(
|
||||
sortThread(thread, threadViewPrefs),
|
||||
hasSession,
|
||||
treeView,
|
||||
),
|
||||
[thread, threadViewPrefs, hasSession, treeView],
|
||||
)
|
||||
const skeleton = React.useMemo(() => {
|
||||
const threadViewPrefs = preferences?.threadViewPrefs
|
||||
if (!threadViewPrefs || !thread) return null
|
||||
|
||||
return createThreadSkeleton(
|
||||
sortThread(thread, threadViewPrefs),
|
||||
hasSession,
|
||||
treeView,
|
||||
)
|
||||
}, [thread, preferences?.threadViewPrefs, hasSession, treeView])
|
||||
|
||||
const error = React.useMemo(() => {
|
||||
if (AppBskyFeedDefs.isNotFoundPost(thread)) {
|
||||
return {
|
||||
title: _(msg`Post not found`),
|
||||
message: _(msg`The post may have been deleted.`),
|
||||
}
|
||||
} else if (skeleton?.highlightedPost.type === 'blocked') {
|
||||
return {
|
||||
title: _(msg`Post hidden`),
|
||||
message: _(
|
||||
msg`You have blocked the author or you have been blocked by the author.`,
|
||||
),
|
||||
}
|
||||
} else if (threadError?.message.startsWith('Post not found')) {
|
||||
return {
|
||||
title: _(msg`Post not found`),
|
||||
message: _(msg`The post may have been deleted.`),
|
||||
}
|
||||
} else if (isThreadError) {
|
||||
return {
|
||||
message: threadError ? cleanError(threadError) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
onCanReply(false)
|
||||
} else if (rootPost) {
|
||||
onCanReply(!rootPost.viewer?.replyDisabled)
|
||||
}
|
||||
}, [rootPost, onCanReply, error])
|
||||
|
||||
// construct content
|
||||
const posts = React.useMemo(() => {
|
||||
if (!skeleton) return []
|
||||
|
||||
const {parents, highlightedPost, replies} = skeleton
|
||||
let arr: RowItem[] = []
|
||||
if (highlightedPost.type === 'post') {
|
||||
|
@ -231,17 +219,11 @@ function PostThreadLoaded({
|
|||
if (!highlightedPost.post.viewer?.replyDisabled) {
|
||||
arr.push(REPLY_PROMPT)
|
||||
}
|
||||
if (highlightedPost.ctx.isChildLoading) {
|
||||
arr.push(CHILD_SPINNER)
|
||||
} else {
|
||||
for (let i = 0; i < replies.length; i++) {
|
||||
arr.push(replies[i])
|
||||
if (i === maxReplies) {
|
||||
arr.push(LOAD_MORE)
|
||||
break
|
||||
}
|
||||
for (let i = 0; i < replies.length; i++) {
|
||||
arr.push(replies[i])
|
||||
if (i === maxReplies) {
|
||||
break
|
||||
}
|
||||
arr.push(BOTTOM_COMPONENT)
|
||||
}
|
||||
}
|
||||
return arr
|
||||
|
@ -256,7 +238,7 @@ function PostThreadLoaded({
|
|||
return
|
||||
}
|
||||
// wait for loading to finish
|
||||
if (thread.type === 'post' && !!thread.parent) {
|
||||
if (thread?.type === 'post' && !!thread.parent) {
|
||||
function onMeasure(pageY: number) {
|
||||
ref.current?.scrollToOffset({
|
||||
animated: false,
|
||||
|
@ -280,10 +262,10 @@ function PostThreadLoaded({
|
|||
// To work around this, we prepend rows after scroll bumps against the top and rests.
|
||||
const needsBumpMaxParents = React.useRef(false)
|
||||
const onStartReached = React.useCallback(() => {
|
||||
if (maxParents < skeleton.parents.length) {
|
||||
if (skeleton?.parents && maxParents < skeleton.parents.length) {
|
||||
needsBumpMaxParents.current = true
|
||||
}
|
||||
}, [maxParents, skeleton.parents.length])
|
||||
}, [maxParents, skeleton?.parents])
|
||||
const bumpMaxParentsIfNeeded = React.useCallback(() => {
|
||||
if (!isNative) {
|
||||
return
|
||||
|
@ -296,6 +278,11 @@ function PostThreadLoaded({
|
|||
const onMomentumScrollEnd = bumpMaxParentsIfNeeded
|
||||
const onScrollToTop = bumpMaxParentsIfNeeded
|
||||
|
||||
const onEndReached = React.useCallback(() => {
|
||||
if (isFetching || posts.length < maxReplies) return
|
||||
setMaxReplies(prev => prev + 50)
|
||||
}, [isFetching, maxReplies, posts.length])
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({item, index}: {item: RowItem; index: number}) => {
|
||||
if (item === TOP_COMPONENT) {
|
||||
|
@ -326,46 +313,6 @@ function PostThreadLoaded({
|
|||
</Text>
|
||||
</View>
|
||||
)
|
||||
} else if (item === LOAD_MORE) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => setMaxReplies(n => n + 50)}
|
||||
style={[pal.border, pal.view, styles.itemContainer]}
|
||||
accessibilityLabel={_(msg`Load more posts`)}
|
||||
accessibilityHint="">
|
||||
<View
|
||||
style={[
|
||||
pal.viewLight,
|
||||
{paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6},
|
||||
]}>
|
||||
<Text type="lg-medium" style={pal.text}>
|
||||
<Trans>Load more posts</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
} else if (item === BOTTOM_COMPONENT) {
|
||||
// HACK
|
||||
// due to some complexities with how flatlist works, this is the easiest way
|
||||
// I could find to get a border positioned directly under the last item
|
||||
// -prf
|
||||
return (
|
||||
<View
|
||||
// @ts-ignore web-only
|
||||
style={{
|
||||
// Leave enough space below that the scroll doesn't jump
|
||||
height: isNative ? 600 : '100vh',
|
||||
borderTopWidth: 1,
|
||||
borderColor: pal.colors.border,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else if (item === CHILD_SPINNER) {
|
||||
return (
|
||||
<View style={[pal.border, styles.childSpinner]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)
|
||||
} else if (isThreadPost(item)) {
|
||||
const prev = isThreadPost(posts[index - 1])
|
||||
? (posts[index - 1] as ThreadPost)
|
||||
|
@ -374,7 +321,9 @@ function PostThreadLoaded({
|
|||
? (posts[index - 1] as ThreadPost)
|
||||
: undefined
|
||||
const hasUnrevealedParents =
|
||||
index === 0 && maxParents < skeleton.parents.length
|
||||
index === 0 &&
|
||||
skeleton?.parents &&
|
||||
maxParents < skeleton.parents.length
|
||||
return (
|
||||
<View
|
||||
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
|
||||
|
@ -391,9 +340,9 @@ function PostThreadLoaded({
|
|||
showChildReplyLine={item.ctx.showChildReplyLine}
|
||||
showParentReplyLine={item.ctx.showParentReplyLine}
|
||||
hasPrecedingItem={
|
||||
!!prev?.ctx.showChildReplyLine || hasUnrevealedParents
|
||||
!!prev?.ctx.showChildReplyLine || !!hasUnrevealedParents
|
||||
}
|
||||
onPostReply={onRefresh}
|
||||
onPostReply={refetch}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
@ -403,142 +352,62 @@ function PostThreadLoaded({
|
|||
[
|
||||
hasSession,
|
||||
isTabletOrMobile,
|
||||
_,
|
||||
isMobile,
|
||||
onPressReply,
|
||||
pal.border,
|
||||
pal.viewLight,
|
||||
pal.textLight,
|
||||
pal.view,
|
||||
pal.text,
|
||||
pal.colors.border,
|
||||
posts,
|
||||
onRefresh,
|
||||
skeleton?.parents,
|
||||
maxParents,
|
||||
deferParents,
|
||||
treeView,
|
||||
skeleton.parents.length,
|
||||
maxParents,
|
||||
_,
|
||||
refetch,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<List
|
||||
ref={ref}
|
||||
data={posts}
|
||||
keyExtractor={item => item._reactKey}
|
||||
renderItem={renderItem}
|
||||
onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
|
||||
onStartReached={onStartReached}
|
||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||
onScrollToTop={onScrollToTop}
|
||||
maintainVisibleContentPosition={
|
||||
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
|
||||
}
|
||||
style={s.hContentRegion}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
removeClippedSubviews={isAndroid ? false : undefined}
|
||||
windowSize={11}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PostThreadBlocked() {
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Home')
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<CenteredView>
|
||||
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
|
||||
<Text type="title-lg" style={[pal.text, s.mb5]}>
|
||||
<Trans>Post hidden</Trans>
|
||||
</Text>
|
||||
<Text type="md" style={[pal.text, s.mb10]}>
|
||||
<Trans>
|
||||
You have blocked the author or you have been blocked by the author.
|
||||
</Trans>
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onPressBack}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Back`)}
|
||||
accessibilityHint="">
|
||||
<Text type="2xl" style={pal.link}>
|
||||
<FontAwesomeIcon
|
||||
icon="angle-left"
|
||||
style={[pal.link as FontAwesomeIconStyle, s.mr5]}
|
||||
size={14}
|
||||
<>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={!preferences || !thread}
|
||||
isError={!!error}
|
||||
onRetry={refetch}
|
||||
errorTitle={error?.title}
|
||||
errorMessage={error?.message}
|
||||
/>
|
||||
{!error && thread && (
|
||||
<List
|
||||
ref={ref}
|
||||
data={posts}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
|
||||
onStartReached={onStartReached}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
onMomentumScrollEnd={onMomentumScrollEnd}
|
||||
onScrollToTop={onScrollToTop}
|
||||
maintainVisibleContentPosition={
|
||||
isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
|
||||
}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
removeClippedSubviews={isAndroid ? false : undefined}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetching={isFetching}
|
||||
onRetry={refetch}
|
||||
// 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
|
||||
// work without causing weird jumps on web or glitches on native
|
||||
height={windowHeight - 200}
|
||||
/>
|
||||
<Trans context="action">Back</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function PostThreadError({
|
||||
onRefresh,
|
||||
notFound,
|
||||
error,
|
||||
}: {
|
||||
onRefresh: () => void
|
||||
notFound: boolean
|
||||
error: Error | null
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('Home')
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
|
||||
<Text type="title-lg" style={[pal.text, s.mb5]}>
|
||||
<Trans>Post not found</Trans>
|
||||
</Text>
|
||||
<Text type="md" style={[pal.text, s.mb10]}>
|
||||
<Trans>The post may have been deleted.</Trans>
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onPressBack}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Back`)}
|
||||
accessibilityHint="">
|
||||
<Text type="2xl" style={pal.link}>
|
||||
<FontAwesomeIcon
|
||||
icon="angle-left"
|
||||
style={[pal.link as FontAwesomeIconStyle, s.mr5]}
|
||||
size={14}
|
||||
/>
|
||||
<Trans>Back</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<CenteredView>
|
||||
<ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
|
||||
</CenteredView>
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -558,7 +427,9 @@ function createThreadSkeleton(
|
|||
node: ThreadNode,
|
||||
hasSession: boolean,
|
||||
treeView: boolean,
|
||||
): ThreadSkeletonParts {
|
||||
): ThreadSkeletonParts | null {
|
||||
if (!node) return null
|
||||
|
||||
return {
|
||||
parents: Array.from(flattenThreadParents(node, hasSession)),
|
||||
highlightedPost: node,
|
||||
|
@ -615,7 +486,10 @@ function hasPwiOptOut(node: ThreadPost) {
|
|||
return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
|
||||
}
|
||||
|
||||
function hasBranchingReplies(node: ThreadNode) {
|
||||
function hasBranchingReplies(node?: ThreadNode) {
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
if (node.type !== 'post') {
|
||||
return false
|
||||
}
|
||||
|
@ -629,20 +503,9 @@ function hasBranchingReplies(node: ThreadNode) {
|
|||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
notFoundContainer: {
|
||||
margin: 10,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 6,
|
||||
},
|
||||
itemContainer: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 18,
|
||||
},
|
||||
childSpinner: {
|
||||
borderTopWidth: 1,
|
||||
paddingTop: 40,
|
||||
paddingBottom: 200,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -205,11 +205,7 @@ let PostThreadItemLoaded = ({
|
|||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
text: record.text,
|
||||
author: {
|
||||
handle: post.author.handle,
|
||||
displayName: post.author.displayName,
|
||||
avatar: post.author.avatar,
|
||||
},
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation,
|
||||
},
|
||||
|
@ -256,6 +252,7 @@ let PostThreadItemLoaded = ({
|
|||
handle={post.author.handle}
|
||||
avatar={post.author.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
|
@ -452,6 +449,7 @@ let PostThreadItemLoaded = ({
|
|||
handle={post.author.handle}
|
||||
avatar={post.author.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
|
||||
{showChildReplyLine && (
|
||||
|
@ -540,7 +538,7 @@ let PostThreadItemLoaded = ({
|
|||
title={itemTitle}
|
||||
noFeedback>
|
||||
<Text type="sm-medium" style={pal.textLight}>
|
||||
More
|
||||
<Trans>More</Trans>
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="angle-right"
|
||||
|
|
|
@ -118,11 +118,7 @@ function PostInner({
|
|||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
text: record.text,
|
||||
author: {
|
||||
handle: post.author.handle,
|
||||
displayName: post.author.displayName,
|
||||
avatar: post.author.avatar,
|
||||
},
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation,
|
||||
},
|
||||
|
@ -144,6 +140,7 @@ function PostInner({
|
|||
handle={post.author.handle}
|
||||
avatar={post.author.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
|
|
|
@ -126,11 +126,7 @@ let FeedItemInner = ({
|
|||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
text: record.text || '',
|
||||
author: {
|
||||
handle: post.author.handle,
|
||||
displayName: post.author.displayName,
|
||||
avatar: post.author.avatar,
|
||||
},
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation,
|
||||
},
|
||||
|
@ -243,6 +239,7 @@ let FeedItemInner = ({
|
|||
handle={post.author.handle}
|
||||
avatar={post.author.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
{isThreadParent && (
|
||||
<View
|
||||
|
|
|
@ -49,6 +49,7 @@ export function ProfileCard({
|
|||
const pal = usePalette('default')
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const moderationOpts = useModerationOpts()
|
||||
const isLabeler = profile?.associated?.labeler
|
||||
if (!moderationOpts) {
|
||||
return null
|
||||
}
|
||||
|
@ -79,6 +80,7 @@ export function ProfileCard({
|
|||
size={40}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={isLabeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
|
@ -101,7 +103,7 @@ export function ProfileCard({
|
|||
/>
|
||||
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
|
||||
</View>
|
||||
{renderButton ? (
|
||||
{renderButton && !isLabeler ? (
|
||||
<View style={styles.layoutButton}>{renderButton(profile)}</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
|
@ -223,6 +225,7 @@ function FollowersList({
|
|||
avatar={f.avatar}
|
||||
size={32}
|
||||
moderation={mod.ui('avatar')}
|
||||
type={f.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
@ -1,39 +1,66 @@
|
|||
import React from 'react'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {LoadingScreen} from '../util/LoadingScreen'
|
||||
import {List} from '../util/List'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||
import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
|
||||
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||
import {logger} from '#/logger'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {
|
||||
ListFooter,
|
||||
ListHeaderDesktop,
|
||||
ListMaybePlaceholder,
|
||||
} from '#/components/Lists'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useSession} from 'state/session'
|
||||
import {View} from 'react-native'
|
||||
|
||||
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
|
||||
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
}
|
||||
|
||||
function keyExtractor(item: ActorDefs.ProfileViewBasic) {
|
||||
return item.did
|
||||
}
|
||||
|
||||
export function ProfileFollowers({name}: {name: string}) {
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {
|
||||
data: resolvedDid,
|
||||
isLoading: isDidLoading,
|
||||
error: resolveError,
|
||||
isFetching: isFetchingDid,
|
||||
} = useResolveDidQuery(name)
|
||||
const {
|
||||
data,
|
||||
isLoading: isFollowersLoading,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useProfileFollowersQuery(resolvedDid)
|
||||
|
||||
const isError = React.useMemo(
|
||||
() => !!resolveError || !!error,
|
||||
[resolveError, error],
|
||||
)
|
||||
|
||||
const isMe = React.useMemo(() => {
|
||||
return resolvedDid === currentAccount?.did
|
||||
}, [resolvedDid, currentAccount?.did])
|
||||
|
||||
const followers = React.useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.followers)
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
|
@ -47,7 +74,7 @@ export function ProfileFollowers({name}: {name: string}) {
|
|||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
if (isFetching || !hasNextPage || !!error) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
|
@ -55,57 +82,38 @@ export function ProfileFollowers({name}: {name: string}) {
|
|||
}
|
||||
}
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({item}: {item: ActorDefs.ProfileViewBasic}) => (
|
||||
<ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
if (isFetchingDid || !isFetched) {
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
// error
|
||||
// =
|
||||
if (resolveError || isError) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<ErrorMessage
|
||||
message={cleanError(resolveError || error)}
|
||||
onPressTryAgain={onRefresh}
|
||||
/>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
// loaded
|
||||
// =
|
||||
return (
|
||||
<List
|
||||
data={followers}
|
||||
keyExtractor={item => item.did}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={15}
|
||||
// FIXME(dan)
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
ListFooterComponent={() => (
|
||||
<View style={styles.footer}>
|
||||
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
|
||||
</View>
|
||||
<View style={{flex: 1}}>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isDidLoading || isFollowersLoading}
|
||||
isEmpty={followers.length < 1}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyMessage={
|
||||
isMe
|
||||
? _(msg`You do not have any followers.`)
|
||||
: _(msg`This user doesn't have any followers.`)
|
||||
}
|
||||
errorMessage={cleanError(resolveError || error)}
|
||||
onRetry={isError ? refetch : undefined}
|
||||
/>
|
||||
{followers.length > 0 && (
|
||||
<List
|
||||
data={followers}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Followers`)} />}
|
||||
ListFooterComponent={<ListFooter isFetching={isFetchingNextPage} />}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
)}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
footer: {
|
||||
height: 200,
|
||||
paddingTop: 20,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -1,39 +1,65 @@
|
|||
import React from 'react'
|
||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||
import {CenteredView} from '../util/Views'
|
||||
import {LoadingScreen} from '../util/LoadingScreen'
|
||||
import {List} from '../util/List'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {ProfileCardWithFollowBtn} from './ProfileCard'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
|
||||
import {logger} from '#/logger'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {
|
||||
ListFooter,
|
||||
ListHeaderDesktop,
|
||||
ListMaybePlaceholder,
|
||||
} from '#/components/Lists'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useSession} from 'state/session'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
|
||||
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
}
|
||||
|
||||
function keyExtractor(item: ActorDefs.ProfileViewBasic) {
|
||||
return item.did
|
||||
}
|
||||
|
||||
export function ProfileFollows({name}: {name: string}) {
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {
|
||||
data: resolvedDid,
|
||||
isLoading: isDidLoading,
|
||||
error: resolveError,
|
||||
isFetching: isFetchingDid,
|
||||
} = useResolveDidQuery(name)
|
||||
const {
|
||||
data,
|
||||
isLoading: isFollowsLoading,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useProfileFollowsQuery(resolvedDid)
|
||||
|
||||
const isError = React.useMemo(
|
||||
() => !!resolveError || !!error,
|
||||
[resolveError, error],
|
||||
)
|
||||
|
||||
const isMe = React.useMemo(() => {
|
||||
return resolvedDid === currentAccount?.did
|
||||
}, [resolvedDid, currentAccount?.did])
|
||||
|
||||
const follows = React.useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.follows)
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
|
@ -47,7 +73,7 @@ export function ProfileFollows({name}: {name: string}) {
|
|||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
if (isFetching || !hasNextPage || !!error) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
|
@ -55,57 +81,38 @@ export function ProfileFollows({name}: {name: string}) {
|
|||
}
|
||||
}
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
({item}: {item: ActorDefs.ProfileViewBasic}) => (
|
||||
<ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
if (isFetchingDid || !isFetched) {
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
// error
|
||||
// =
|
||||
if (resolveError || isError) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<ErrorMessage
|
||||
message={cleanError(resolveError || error)}
|
||||
onPressTryAgain={onRefresh}
|
||||
/>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
// loaded
|
||||
// =
|
||||
return (
|
||||
<List
|
||||
data={follows}
|
||||
keyExtractor={item => item.did}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={15}
|
||||
// FIXME(dan)
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
ListFooterComponent={() => (
|
||||
<View style={styles.footer}>
|
||||
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
|
||||
</View>
|
||||
<>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isDidLoading || isFollowsLoading}
|
||||
isEmpty={follows.length < 1}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyMessage={
|
||||
isMe
|
||||
? _(msg`You are not following anyone.`)
|
||||
: _(msg`This user isn't following anyone.`)
|
||||
}
|
||||
errorMessage={cleanError(resolveError || error)}
|
||||
onRetry={isError ? refetch : undefined}
|
||||
/>
|
||||
{follows.length > 0 && (
|
||||
<List
|
||||
data={follows}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Following`)} />}
|
||||
ListFooterComponent={<ListFooter isFetching={isFetchingNextPage} />}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
)}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
footer: {
|
||||
height: 200,
|
||||
paddingTop: 20,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -21,7 +21,8 @@ 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'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
|
||||
const OUTER_PADDING = 10
|
||||
const INNER_PADDING = 14
|
||||
|
@ -98,9 +99,11 @@ export function ProfileHeaderSuggestedFollows({
|
|||
<SuggestedFollowSkeleton />
|
||||
</>
|
||||
) : data ? (
|
||||
data.suggestions.map(profile => (
|
||||
<SuggestedFollow key={profile.did} profile={profile} />
|
||||
))
|
||||
data.suggestions
|
||||
.filter(s => (s.associated?.labeler ? false : true))
|
||||
.map(profile => (
|
||||
<SuggestedFollow key={profile.did} profile={profile} />
|
||||
))
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
|
@ -168,6 +171,7 @@ function SuggestedFollow({
|
|||
}) {
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
|
@ -181,20 +185,20 @@ function SuggestedFollow({
|
|||
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.`))
|
||||
}
|
||||
}
|
||||
}, [queueFollow, track])
|
||||
}, [queueFollow, track, _])
|
||||
|
||||
const onPressUnfollow = React.useCallback(async () => {
|
||||
try {
|
||||
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.`))
|
||||
}
|
||||
}
|
||||
}, [queueUnfollow])
|
||||
}, [queueUnfollow, _])
|
||||
|
||||
if (!moderationOpts) {
|
||||
return null
|
||||
|
@ -239,7 +243,7 @@ function SuggestedFollow({
|
|||
</View>
|
||||
|
||||
<Button
|
||||
label={following ? 'Unfollow' : 'Follow'}
|
||||
label={following ? _(msg`Unfollow`) : _(msg`Follow`)}
|
||||
type="inverted"
|
||||
labelStyle={{textAlign: 'center'}}
|
||||
onPress={following ? onPressUnfollow : onPressFollow}
|
||||
|
|
|
@ -11,16 +11,11 @@ import {sanitizeHandle} from 'lib/strings/handles'
|
|||
import {isAndroid, isWeb} from 'platform/detection'
|
||||
import {TimeElapsed} from './TimeElapsed'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {ModerationDecision, ModerationUI} from '@atproto/api'
|
||||
import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api'
|
||||
import {usePrefetchProfileQuery} from '#/state/queries/profile'
|
||||
|
||||
interface PostMetaOpts {
|
||||
author: {
|
||||
avatar?: string
|
||||
did: string
|
||||
handle: string
|
||||
displayName?: string | undefined
|
||||
}
|
||||
author: AppBskyActorDefs.ProfileViewBasic
|
||||
moderation: ModerationDecision | undefined
|
||||
authorHasWarning: boolean
|
||||
postHref: string
|
||||
|
@ -47,6 +42,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
|||
avatar={opts.author.avatar}
|
||||
size={opts.avatarSize || 16}
|
||||
moderation={opts.avatarModeration}
|
||||
type={opts.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue