Merge branch 'bluesky-social:main' into zh

zio/stable
Kuwa Lee 2024-06-20 16:26:22 +08:00 committed by GitHub
commit a33e370d22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 323 additions and 160 deletions

View File

@ -84,6 +84,7 @@ export const createHitslop = (size: number): Insets => ({
export const HITSLOP_10 = createHitslop(10)
export const HITSLOP_20 = createHitslop(20)
export const HITSLOP_30 = createHitslop(30)
export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10}
export const BACK_HITSLOP = HITSLOP_30
export const MAX_POST_LINES = 25

View File

@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {countLines} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles'
import {isNative, isWeb} from 'platform/detection'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf'
@ -35,7 +35,7 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
import {PostAlerts} from '../../../components/moderation/PostAlerts'
import {PostHider} from '../../../components/moderation/PostHider'
import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
import {WhoCanReply} from '../threadgate/WhoCanReply'
import {WhoCanReplyBlock, WhoCanReplyInline} from '../threadgate/WhoCanReply'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format'
@ -340,6 +340,7 @@ let PostThreadItemLoaded = ({
</ContentHider>
<ExpandedPostDetails
post={post}
isThreadAuthor={isThreadAuthor}
translatorUrl={translatorUrl}
needsTranslation={needsTranslation}
/>
@ -396,11 +397,6 @@ let PostThreadItemLoaded = ({
</View>
</View>
</View>
<WhoCanReply
post={post}
isThreadAuthor={isThreadAuthor}
style={{borderBottomWidth: isNative ? 1 : 0}}
/>
</>
)
} else {
@ -579,14 +575,7 @@ let PostThreadItemLoaded = ({
) : undefined}
</PostHider>
</PostOuterWrapper>
<WhoCanReply
post={post}
style={{
marginTop: 4,
borderBottomWidth: 1,
}}
isThreadAuthor={isThreadAuthor}
/>
<WhoCanReplyBlock post={post} isThreadAuthor={isThreadAuthor} />
</>
)
}
@ -654,10 +643,12 @@ function PostOuterWrapper({
function ExpandedPostDetails({
post,
isThreadAuthor,
needsTranslation,
translatorUrl,
}: {
post: AppBskyFeedDefs.PostView
isThreadAuthor: boolean
needsTranslation: boolean
translatorUrl: string
}) {
@ -670,14 +661,23 @@ function ExpandedPostDetails({
}, [openLink, translatorUrl])
return (
<View style={[s.flexRow, s.mt2, s.mb10]}>
<Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text>
<View
style={[
a.flex_row,
a.align_center,
a.flex_wrap,
a.gap_sm,
s.mt2,
s.mb10,
]}>
<Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text>
<WhoCanReplyInline post={post} isThreadAuthor={isThreadAuthor} />
{needsTranslation && (
<>
<Text style={pal.textLight}> &middot; </Text>
<Text style={[a.text_sm, pal.textLight]}>&middot;</Text>
<Text
style={pal.link}
style={[a.text_sm, pal.link]}
title={_(msg`Translate`)}
onPress={onTranslatePress}>
<Trans>Translate</Trans>

View File

@ -1,16 +1,20 @@
import React from 'react'
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
import {
AppBskyFeedDefs,
AppBskyFeedGetPostThread,
AppBskyGraphDefs,
AtUri,
BskyAgent,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {createThreadgate} from '#/lib/api'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette'
import {until} from '#/lib/async/until'
import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {colors} from '#/lib/styles'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
@ -21,84 +25,31 @@ import {
} from '#/state/queries/threadgate'
import {useAgent} from '#/state/session'
import * as Toast from 'view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {Text} from '#/components/Typography'
import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text'
export function WhoCanReply({
post,
isThreadAuthor,
style,
}: {
interface WhoCanReplyProps {
post: AppBskyFeedDefs.PostView
isThreadAuthor: boolean
style?: StyleProp<ViewStyle>
}) {
const {track} = useAnalytics()
const {_} = useLingui()
const pal = usePalette('default')
const agent = useAgent()
const queryClient = useQueryClient()
const {openModal} = useModalControls()
const containerStyles = useColorSchemeStyle(
{
backgroundColor: pal.colors.unreadNotifBg,
},
{
backgroundColor: pal.colors.unreadNotifBg,
},
)
const textStyles = useColorSchemeStyle(
{color: colors.blue5},
{color: colors.blue1},
)
const hoverStyles = useColorSchemeStyle(
{
backgroundColor: colors.white,
},
{
backgroundColor: pal.colors.background,
},
)
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post],
)
const isRootPost = !('reply' in post.record)
}
const onPressEdit = () => {
track('Post:EditThreadgateOpened')
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
openModal({
name: 'threadgate',
settings,
async onConfirm(newSettings: ThreadgateSetting[]) {
try {
if (newSettings.length) {
await createThreadgate(agent, post.uri, newSettings)
} else {
await agent.api.com.atproto.repo.deleteRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: new AtUri(post.uri).rkey,
})
}
Toast.show('Thread settings updated')
queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT],
})
track('Post:ThreadgateEdited')
} catch (err) {
Toast.show(
'There was an issue. Please check your internet connection and try again.',
)
logger.error('Failed to edit threadgate', {message: err})
}
},
})
}
export function WhoCanReplyInline({
post,
isThreadAuthor,
style,
}: WhoCanReplyProps) {
const {_} = useLingui()
const t = useTheme()
const infoDialogControl = useDialogControl()
const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
if (!isRootPost) {
return null
@ -107,65 +58,201 @@ export function WhoCanReply({
return null
}
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
const description = isEverybody
? _(msg`Everybody can reply`)
: isNobody
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
return (
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingLeft: 18,
paddingRight: 14,
paddingVertical: 10,
borderTopWidth: 1,
},
pal.border,
containerStyles,
style,
]}>
<View style={{flex: 1, paddingVertical: 6}}>
<Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
{!settings.length ? (
<Trans>Everybody can reply.</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread are disabled.</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator key={`sep-${i}`} i={i} length={settings.length} />
</>
))}{' '}
can reply.
</Trans>
)}
<>
<Button
label={
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
}
onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
hitSlop={HITSLOP_10}>
{({hovered}) => (
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
<Icon
color={t.palette.contrast_400}
width={16}
settings={settings}
/>
<Text
style={[
a.text_sm,
a.leading_tight,
t.atoms.text_contrast_medium,
hovered && a.underline,
]}>
{description}
</Text>
</View>
)}
</Button>
<InfoDialog control={infoDialogControl} post={post} settings={settings} />
</>
)
}
export function WhoCanReplyBlock({
post,
isThreadAuthor,
style,
}: WhoCanReplyProps) {
const {_} = useLingui()
const t = useTheme()
const infoDialogControl = useDialogControl()
const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
if (!isRootPost) {
return null
}
if (!settings.length && !isThreadAuthor) {
return null
}
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
const description = isEverybody
? _(msg`Everybody can reply`)
: isNobody
? _(msg`Replies on this thread are disabled`)
: _(msg`Some people can reply`)
return (
<>
<Button
label={
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
}
onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
hitSlop={HITSLOP_10}>
{({hovered}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.py_sm,
a.pr_lg,
style,
]}>
<View style={[{paddingLeft: 25, paddingRight: 18}]}>
<Icon color={t.palette.contrast_300} settings={settings} />
</View>
<Text
style={[
a.text_sm,
a.leading_tight,
t.atoms.text_contrast_medium,
hovered && a.underline,
]}>
{description}
</Text>
</View>
)}
</Button>
<InfoDialog control={infoDialogControl} post={post} settings={settings} />
</>
)
}
function Icon({
color,
width,
settings,
}: {
color: string
width?: number
settings: ThreadgateSetting[]
}) {
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group
return <IconComponent fill={color} width={width} />
}
function InfoDialog({
control,
post,
settings,
}: {
control: Dialog.DialogControlProps
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
}) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<InfoDialogInner post={post} settings={settings} />
</Dialog.Outer>
)
}
function InfoDialogInner({
post,
settings,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
}) {
const {_} = useLingui()
return (
<Dialog.ScrollableInner
label={_(msg`Who can reply dialog`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl]}>
<Trans>Who can reply?</Trans>
</Text>
<Rules post={post} settings={settings} />
</View>
{isThreadAuthor && (
<View>
<Button label={_(msg`Edit`)} onPress={onPressEdit}>
{({hovered}) => (
<View
style={[
hovered && hoverStyles,
{paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8},
]}>
<Text type="sm" style={pal.link}>
<Trans>Edit</Trans>
</Text>
</View>
)}
</Button>
</View>
</Dialog.ScrollableInner>
)
}
function Rules({
post,
settings,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
}) {
const t = useTheme()
return (
<Text
style={[
a.text_md,
a.leading_tight,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
{!settings.length ? (
<Trans>Everybody can reply</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread are disabled</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator key={`sep-${i}`} i={i} length={settings.length} />
</>
))}{' '}
can reply
</Trans>
)}
</View>
</Text>
)
}
@ -178,7 +265,7 @@ function Rule({
post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) {
const pal = usePalette('default')
const t = useTheme()
if (rule.type === 'mention') {
return <Trans>mentioned users</Trans>
}
@ -190,7 +277,7 @@ function Rule({
type="sm"
href={makeProfileLink(post.author)}
text={`@${post.author.handle}`}
style={pal.link}
style={{color: t.palette.primary_500}}
/>
</Trans>
)
@ -205,7 +292,7 @@ function Rule({
type="sm"
href={makeListLink(listUrip.hostname, listUrip.rkey)}
text={list.name}
style={pal.link}
style={{color: t.palette.primary_500}}
/>{' '}
members
</Trans>
@ -227,3 +314,78 @@ function Separator({i, length}: {i: number; length: number}) {
}
return <>, </>
}
function useWhoCanReply(post: AppBskyFeedDefs.PostView) {
const agent = useAgent()
const queryClient = useQueryClient()
const {openModal} = useModalControls()
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post],
)
const isRootPost = !('reply' in post.record)
const onPressEdit = () => {
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
openModal({
name: 'threadgate',
settings,
async onConfirm(newSettings: ThreadgateSetting[]) {
try {
if (newSettings.length) {
await createThreadgate(agent, post.uri, newSettings)
} else {
await agent.api.com.atproto.repo.deleteRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: new AtUri(post.uri).rkey,
})
}
await whenAppViewReady(agent, post.uri, res => {
const thread = res.data.thread
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
const fetchedSettings = threadgateViewToSettings(
thread.post.threadgate,
)
return (
JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
)
}
return false
})
Toast.show('Thread settings updated')
queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT],
})
} catch (err) {
Toast.show(
'There was an issue. Please check your internet connection and try again.',
)
logger.error('Failed to edit threadgate', {message: err})
}
},
})
}
return {settings, isRootPost, onPressEdit}
}
async function whenAppViewReady(
agent: BskyAgent,
uri: string,
fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
() =>
agent.app.bsky.feed.getPostThread({
uri,
depth: 0,
}),
)
}

View File

@ -15,7 +15,7 @@ import {
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
import {POST_CTRL_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
@ -215,7 +215,7 @@ let PostCtrls = ({
other: 'Reply (# replies)',
})}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
hitSlop={POST_CTRL_HITSLOP}>
<Bubble
style={[defaultCtrlColor, {pointerEvents: 'none'}]}
width={big ? 22 : 18}
@ -258,7 +258,7 @@ let PostCtrls = ({
})
}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
hitSlop={POST_CTRL_HITSLOP}>
{post.viewer?.like ? (
<HeartIconFilled style={s.likeColor} width={big ? 22 : 18} />
) : (
@ -299,7 +299,7 @@ let PostCtrls = ({
}}
accessibilityLabel={_(msg`Share`)}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
hitSlop={POST_CTRL_HITSLOP}>
<ArrowOutOfBox
style={[defaultCtrlColor, {pointerEvents: 'none'}]}
width={22}
@ -325,7 +325,7 @@ let PostCtrls = ({
record={record}
richText={richText}
style={{padding: 5}}
hitSlop={big ? HITSLOP_20 : HITSLOP_10}
hitSlop={POST_CTRL_HITSLOP}
timestamp={post.indexedAt}
/>
</View>

View File

@ -3,7 +3,7 @@ import {View} from 'react-native'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
import {POST_CTRL_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useRequireAuth} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
@ -67,7 +67,7 @@ let RepostButton = ({
shape="round"
variant="ghost"
color="secondary"
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
hitSlop={POST_CTRL_HITSLOP}>
<Repost style={color} width={big ? 22 : 18} />
{typeof repostCount !== 'undefined' && repostCount > 0 ? (
<Text