Merge branch 'bluesky-social:main' into zh

zio/stable
Kuwa Lee 2024-06-13 15:29:51 +08:00 committed by GitHub
commit ecd51bc6f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 178 additions and 102 deletions

View File

@ -1,14 +1,14 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {msg, plural, Trans} from '@lingui/macro' import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Link} from '#/components/Link' import {Link, LinkProps} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
const AVI_SIZE = 30 const AVI_SIZE = 30
@ -29,9 +29,11 @@ export function shouldShowKnownFollowers(
export function KnownFollowers({ export function KnownFollowers({
profile, profile,
moderationOpts, moderationOpts,
onLinkPress,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
onLinkPress?: LinkProps['onPress']
}) { }) {
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>( const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
new Map(), new Map(),
@ -56,6 +58,7 @@ export function KnownFollowers({
profile={profile} profile={profile}
cachedKnownFollowers={cachedKnownFollowers} cachedKnownFollowers={cachedKnownFollowers}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
onLinkPress={onLinkPress}
/> />
) )
} }
@ -67,10 +70,12 @@ function KnownFollowersInner({
profile, profile,
moderationOpts, moderationOpts,
cachedKnownFollowers, cachedKnownFollowers,
onLinkPress,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
onLinkPress?: LinkProps['onPress']
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@ -82,15 +87,6 @@ function KnownFollowersInner({
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
] ]
// list of users, minus blocks
const returnedCount = cachedKnownFollowers.followers.length
// db count, includes blocks
const fullCount = cachedKnownFollowers.count
// knownFollowers can return up to 5 users, but will exclude blocks
// therefore, if we have less 5 users, use whichever count is lower
const count =
returnedCount < 5 ? Math.min(fullCount, returnedCount) : fullCount
const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
const moderation = moderateProfile(f, moderationOpts) const moderation = moderateProfile(f, moderationOpts)
return { return {
@ -104,12 +100,14 @@ function KnownFollowersInner({
moderation, moderation,
} }
}) })
const count = cachedKnownFollowers.count - Math.min(slice.length, 2)
return ( return (
<Link <Link
label={_( label={_(
msg`Press to view followers of this account that you also follow`, msg`Press to view followers of this account that you also follow`,
)} )}
onPress={onLinkPress}
to={makeProfileLink(profile, 'known-followers')} to={makeProfileLink(profile, 'known-followers')}
style={[ style={[
a.flex_1, a.flex_1,
@ -166,31 +164,37 @@ function KnownFollowersInner({
}, },
]} ]}
numberOfLines={2}> numberOfLines={2}>
<Trans>Followed by</Trans>{' '}
{count > 2 ? ( {count > 2 ? (
<> <Trans>
{slice.slice(0, 2).map(({profile: prof}, i) => ( Followed by{' '}
<Text key={prof.did} style={textStyle}> <Text key={slice[0].profile.did} style={textStyle}>
{prof.displayName} {slice[0].profile.displayName}
{i === 0 && ', '}
</Text>
))}
{', '}
{plural(count - 2, {
one: 'and # other',
other: 'and # others',
})}
</>
) : count === 2 ? (
slice.map(({profile: prof}, i) => (
<Text key={prof.did} style={textStyle}>
{prof.displayName} {i === 0 ? _(msg`and`) + ' ' : ''}
</Text> </Text>
)) ,{' '}
<Text key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
, and{' '}
<Plural value={count - 2} one="# other" other="# others" />
</Trans>
) : count === 2 ? (
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
<Text key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
</Trans>
) : ( ) : (
<Text key={slice[0].profile.did} style={textStyle}> <Trans>
{slice[0].profile.displayName} Followed by{' '}
</Text> <Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>
</Trans>
)} )}
</Text> </Text>
</> </>

View File

@ -5,6 +5,7 @@ import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
import {msg, plural} from '@lingui/macro' import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {getModerationCauseKey} from '#/lib/moderation'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
@ -22,11 +23,16 @@ import {useFollowMethods} from '#/components/hooks/useFollowMethods'
import {useRichText} from '#/components/hooks/useRichText' import {useRichText} from '#/components/hooks/useRichText'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {
KnownFollowers,
shouldShowKnownFollowers,
} from '#/components/KnownFollowers'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ProfileLabel} from '../moderation/ProfileHeaderAlerts'
import {ProfileHoverCardProps} from './types' import {ProfileHoverCardProps} from './types'
const floatingMiddlewares = [ const floatingMiddlewares = [
@ -370,7 +376,10 @@ function Inner({
profile: profileShadow, profile: profileShadow,
logContext: 'ProfileHoverCard', logContext: 'ProfileHoverCard',
}) })
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy const isBlockedUser =
profile.viewer?.blocking ||
profile.viewer?.blockedBy ||
profile.viewer?.blockingByList
const following = formatCount(profile.followsCount || 0) const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0) const followers = formatCount(profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, { const pluralizedFollowers = plural(profile.followersCount || 0, {
@ -401,29 +410,41 @@ function Inner({
/> />
</Link> </Link>
{!isMe && ( {!isMe &&
<Button (isBlockedUser ? (
size="small" <Link
color={profileShadow.viewer?.following ? 'secondary' : 'primary'} to={profileURL}
variant="solid" label={_(msg`View blocked user's profile`)}
label={ onPress={hide}
profileShadow.viewer?.following size="small"
? _(msg`Following`) color="secondary"
: _(msg`Follow`) variant="solid"
} style={[a.rounded_full]}>
style={[a.rounded_full]} <ButtonText>{_(msg`View profile`)}</ButtonText>
onPress={profileShadow.viewer?.following ? unfollow : follow}> </Link>
<ButtonIcon ) : (
position="left" <Button
icon={profileShadow.viewer?.following ? Check : Plus} size="small"
/> color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
<ButtonText> variant="solid"
{profileShadow.viewer?.following label={
? _(msg`Following`) profileShadow.viewer?.following
: _(msg`Follow`)} ? _(msg`Following`)
</ButtonText> : _(msg`Follow`)
</Button> }
)} style={[a.rounded_full]}
onPress={profileShadow.viewer?.following ? unfollow : follow}>
<ButtonIcon
position="left"
icon={profileShadow.viewer?.following ? Check : Plus}
/>
<ButtonText>
{profileShadow.viewer?.following
? _(msg`Following`)
: _(msg`Follow`)}
</ButtonText>
</Button>
))}
</View> </View>
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}> <Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
@ -439,7 +460,19 @@ function Inner({
</View> </View>
</Link> </Link>
{!blockHide && ( {isBlockedUser && (
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
{moderation.ui('profileView').alerts.map(cause => (
<ProfileLabel
key={getModerationCauseKey(cause)}
cause={cause}
disableDetailsDialog
/>
))}
</View>
)}
{!isBlockedUser && (
<> <>
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}> <View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}>
<InlineLinkText <InlineLinkText
@ -473,6 +506,17 @@ function Inner({
/> />
</View> </View>
) : undefined} ) : undefined}
{!isMe &&
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
<View style={[a.flex_row, a.align_center, a.gap_sm, a.pt_md]}>
<KnownFollowers
profile={profile}
moderationOpts={moderationOpts}
onLinkPress={hide}
/>
</View>
)}
</> </>
)} )}
</View> </View>

View File

@ -43,7 +43,13 @@ export function ProfileHeaderAlerts({
) )
} }
function ProfileLabel({cause}: {cause: ModerationCause}) { export function ProfileLabel({
cause,
disableDetailsDialog,
}: {
cause: ModerationCause
disableDetailsDialog?: boolean
}) {
const t = useTheme() const t = useTheme()
const control = useModerationDetailsDialogControl() const control = useModerationDetailsDialogControl()
const desc = useModerationCauseDescription(cause) const desc = useModerationCauseDescription(cause)
@ -51,6 +57,7 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
return ( return (
<> <>
<Button <Button
disabled={disableDetailsDialog}
label={desc.name} label={desc.name}
onPress={() => { onPress={() => {
control.open() control.open()
@ -87,7 +94,9 @@ function ProfileLabel({cause}: {cause: ModerationCause}) {
)} )}
</Button> </Button>
<ModerationDetailsDialog control={control} modcause={cause} /> {!disableDetailsDialog && (
<ModerationDetailsDialog control={control} modcause={cause} />
)}
</> </>
) )
} }

View File

@ -94,6 +94,7 @@ export function usePrefetchProfileQuery() {
const prefetchProfileQuery = useCallback( const prefetchProfileQuery = useCallback(
async (did: string) => { async (did: string) => {
await queryClient.prefetchQuery({ await queryClient.prefetchQuery({
staleTime: STALE.SECONDS.THIRTY,
queryKey: RQKEY(did), queryKey: RQKEY(did),
queryFn: async () => { queryFn: async () => {
const res = await agent.getProfile({actor: did || ''}) const res = await agent.getProfile({actor: did || ''})

View File

@ -150,16 +150,27 @@ export const TextInput = React.forwardRef(function TextInputImpl(
attributes: { attributes: {
class: modeClass, class: modeClass,
}, },
handlePaste: (_, event) => { handlePaste: (view, event) => {
const items = event.clipboardData?.items const clipboardData = event.clipboardData
if (items === undefined) { if (clipboardData) {
return if (clipboardData.types.includes('text/html')) {
// Rich-text formatting is pasted, try retrieving plain text
const text = clipboardData.getData('text/plain')
// `pasteText` will invoke this handler again, but `clipboardData` will be null.
view.pasteText(text)
// Return `true` to prevent ProseMirror's default paste behavior.
return true
} else {
// Otherwise, try retrieving images from the clipboard
getImageFromUri(clipboardData.items, (uri: string) => {
textInputWebEmitter.emit('photo-pasted', uri)
})
}
} }
getImageFromUri(items, (uri: string) => {
textInputWebEmitter.emit('photo-pasted', uri)
})
}, },
handleKeyDown: (_, event) => { handleKeyDown: (_, event) => {
if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') { if ((event.metaKey || event.ctrlKey) && event.code === 'Enter') {

View File

@ -11,6 +11,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {niceDate} from 'lib/strings/time' import {niceDate} from 'lib/strings/time'
import {TypographyVariant} from 'lib/ThemeContext' import {TypographyVariant} from 'lib/ThemeContext'
import {isAndroid, isWeb} from 'platform/detection' import {isAndroid, isWeb} from 'platform/detection'
import {atoms as a} from '#/alf'
import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {TextLinkOnWebOnly} from './Link' import {TextLinkOnWebOnly} from './Link'
import {Text} from './text/Text' import {Text} from './text/Text'
@ -39,9 +40,13 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
const prefetchProfileQuery = usePrefetchProfileQuery() const prefetchProfileQuery = usePrefetchProfileQuery()
const profileLink = makeProfileLink(opts.author) const profileLink = makeProfileLink(opts.author)
const onPointerEnter = isWeb const prefetchedProfile = React.useRef(false)
? () => prefetchProfileQuery(opts.author.did) const onPointerMove = React.useCallback(() => {
: undefined if (!prefetchedProfile.current) {
prefetchedProfile.current = true
prefetchProfileQuery(opts.author.did)
}
}, [opts.author.did, prefetchProfileQuery])
const queryClient = useQueryClient() const queryClient = useQueryClient()
const onOpenAuthor = opts.onOpenAuthor const onOpenAuthor = opts.onOpenAuthor
@ -66,37 +71,39 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
</View> </View>
)} )}
<ProfileHoverCard inline did={opts.author.did}> <ProfileHoverCard inline did={opts.author.did}>
<Text <View
numberOfLines={1} onPointerMove={isWeb ? onPointerMove : undefined}
style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}> style={[a.flex_1]}>
<TextLinkOnWebOnly <Text
type={opts.displayNameType || 'lg-bold'} numberOfLines={1}
style={[pal.text]} style={[styles.maxWidth, pal.textLight, opts.displayNameStyle]}>
lineHeight={1.2} <TextLinkOnWebOnly
disableMismatchWarning type={opts.displayNameType || 'lg-bold'}
text={ style={[pal.text]}
<> lineHeight={1.2}
{sanitizeDisplayName( disableMismatchWarning
displayName, text={
opts.moderation?.ui('displayName'), <>
)} {sanitizeDisplayName(
</> displayName,
} opts.moderation?.ui('displayName'),
href={profileLink} )}
onBeforePress={onBeforePressAuthor} </>
onPointerEnter={onPointerEnter} }
/> href={profileLink}
<TextLinkOnWebOnly onBeforePress={onBeforePressAuthor}
type="md" />
disableMismatchWarning <TextLinkOnWebOnly
style={[pal.textLight, {flexShrink: 4}]} type="md"
text={'\xa0' + sanitizeHandle(handle, '@')} disableMismatchWarning
href={profileLink} style={[pal.textLight, {flexShrink: 4}]}
onBeforePress={onBeforePressAuthor} text={'\xa0' + sanitizeHandle(handle, '@')}
onPointerEnter={onPointerEnter} href={profileLink}
anchorNoUnderline onBeforePress={onBeforePressAuthor}
/> anchorNoUnderline
</Text> />
</Text>
</View>
</ProfileHoverCard> </ProfileHoverCard>
{!isAndroid && ( {!isAndroid && (
<Text <Text