add composer nudge

This commit is contained in:
Samuel Newman
2025-04-21 14:43:54 +03:00
parent 35daa5a343
commit d9786aeebe
4 changed files with 403 additions and 122 deletions
+4
View File
@@ -195,6 +195,10 @@ export const EMOJI_REACTION_LIMIT = 5
export const urls = {
website: {
support: {
communityGuidelines:
'https://bsky.social/about/support/community-guidelines',
},
blog: {
initialVerificationAnnouncement: `https://bsky.social/about/blog/04-21-2025-verification`,
},
+5
View File
@@ -146,6 +146,11 @@ export type MetricEvents = {
'composer:gif:open': {}
'composer:gif:select': {}
'composer:nudge:feedback': {
replyTo?: string
text: string
}
// Data events
'account:create:begin': {}
'account:create:success': {
+152 -122
View File
@@ -128,6 +128,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import LanguageNudgeDialog from './LanguageNudge'
import {
type ComposerAction,
composerReducer,
@@ -376,136 +377,156 @@ export const ComposePost = ({
),
)
const onPressPublish = React.useCallback(async () => {
if (isPublishing) {
return
}
const languageNudgeControl = useDialogControl()
if (!canPost) {
return
}
if (
thread.posts.some(
post =>
post.embed.media?.type === 'video' &&
post.embed.media.video.asset &&
post.embed.media.video.status !== 'done',
)
) {
setPublishOnUpload(true)
return
}
setError('')
setIsPublishing(true)
let postUri
try {
postUri = (
await apilib.post(agent, queryClient, {
thread,
replyTo: replyTo?.uri,
onStateChange: setPublishingStage,
langs: toPostLanguages(langPrefs.postLanguage),
})
).uris[0]
try {
await whenAppViewReady(agent, postUri, res => {
const postedThread = res.data.thread
return AppBskyFeedDefs.isThreadViewPost(postedThread)
})
} catch (waitErr: any) {
logger.error(waitErr, {
message: `Waiting for app view failed`,
})
// Keep going because the post *was* published.
const onPressPublish = useCallback(
async (bypassNudge = false) => {
if (isPublishing) {
return
}
} catch (e: any) {
logger.error(e, {
message: `Composer: create post failed`,
hasImages: thread.posts.some(p => p.embed.media?.type === 'images'),
})
let err = cleanError(e.message)
if (err.includes('not locate record')) {
err = _(
msg`We're sorry! The post you are replying to has been deleted.`,
if (!canPost) {
return
}
if (
thread.posts.some(
post =>
post.embed.media?.type === 'video' &&
post.embed.media.video.asset &&
post.embed.media.video.status !== 'done',
)
} else if (e instanceof EmbeddingDisabledError) {
err = _(msg`This post's author has disabled quote posts.`)
) {
setPublishOnUpload(true)
return
}
setError(err)
setIsPublishing(false)
return
} finally {
if (postUri) {
let index = 0
for (let post of thread.posts) {
logEvent('post:create', {
imageCount:
post.embed.media?.type === 'images'
? post.embed.media.images.length
: 0,
isReply: index > 0 || !!replyTo,
isPartOfThread: thread.posts.length > 1,
hasLink: !!post.embed.link,
hasQuote: !!post.embed.quote,
langs: langPrefs.postLanguage,
logContext: 'Composer',
setError('')
setIsPublishing(true)
if (!bypassNudge) {
// api call goes here
await new Promise(res => setTimeout(res, 500))
if (true) {
setIsPublishing(false)
languageNudgeControl.open()
return
}
}
let postUri
try {
postUri = (
await apilib.post(agent, queryClient, {
thread,
replyTo: replyTo?.uri,
onStateChange: setPublishingStage,
langs: toPostLanguages(langPrefs.postLanguage),
})
index++
).uris[0]
try {
await whenAppViewReady(agent, postUri, res => {
const postedThread = res.data.thread
return AppBskyFeedDefs.isThreadViewPost(postedThread)
})
} catch (waitErr: any) {
logger.error(waitErr, {
message: `Waiting for app view failed`,
})
// Keep going because the post *was* published.
}
}
if (thread.posts.length > 1) {
logEvent('thread:create', {
postCount: thread.posts.length,
isReply: !!replyTo,
} catch (e: any) {
logger.error(e, {
message: `Composer: create post failed`,
hasImages: thread.posts.some(p => p.embed.media?.type === 'images'),
})
}
}
if (postUri && !replyTo) {
emitPostCreated()
}
setLangPrefs.savePostLanguageToHistory()
if (initQuote) {
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
whenAppViewReady(agent, initQuote.uri, res => {
const quotedThread = res.data.thread
if (
AppBskyFeedDefs.isThreadViewPost(quotedThread) &&
quotedThread.post.quoteCount !== initQuote.quoteCount
) {
onPost?.(postUri)
return true
let err = cleanError(e.message)
if (err.includes('not locate record')) {
err = _(
msg`We're sorry! The post you are replying to has been deleted.`,
)
} else if (e instanceof EmbeddingDisabledError) {
err = _(msg`This post's author has disabled quote posts.`)
}
return false
})
} else {
onPost?.(postUri)
}
onClose()
Toast.show(
thread.posts.length > 1
? _(msg`Your posts have been published`)
: replyTo
? _(msg`Your reply has been published`)
: _(msg`Your post has been published`),
)
}, [
_,
agent,
thread,
canPost,
isPublishing,
langPrefs.postLanguage,
onClose,
onPost,
initQuote,
replyTo,
setLangPrefs,
queryClient,
])
setError(err)
setIsPublishing(false)
return
} finally {
if (postUri) {
let index = 0
for (let post of thread.posts) {
logEvent('post:create', {
imageCount:
post.embed.media?.type === 'images'
? post.embed.media.images.length
: 0,
isReply: index > 0 || !!replyTo,
isPartOfThread: thread.posts.length > 1,
hasLink: !!post.embed.link,
hasQuote: !!post.embed.quote,
langs: langPrefs.postLanguage,
logContext: 'Composer',
})
index++
}
}
if (thread.posts.length > 1) {
logEvent('thread:create', {
postCount: thread.posts.length,
isReply: !!replyTo,
})
}
}
if (postUri && !replyTo) {
emitPostCreated()
}
setLangPrefs.savePostLanguageToHistory()
if (initQuote) {
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
whenAppViewReady(agent, initQuote.uri, res => {
const quotedThread = res.data.thread
if (
AppBskyFeedDefs.isThreadViewPost(quotedThread) &&
quotedThread.post.quoteCount !== initQuote.quoteCount
) {
onPost?.(postUri)
return true
}
return false
})
} else {
onPost?.(postUri)
}
onClose()
Toast.show(
thread.posts.length > 1
? _(msg`Your posts have been published`)
: replyTo
? _(msg`Your reply has been published`)
: _(msg`Your post has been published`),
)
},
[
_,
agent,
thread,
canPost,
isPublishing,
langPrefs.postLanguage,
onClose,
onPost,
initQuote,
replyTo,
setLangPrefs,
queryClient,
languageNudgeControl,
],
)
const postAnyway = useCallback(() => {
return onPressPublish(true)
}, [onPressPublish])
// Preserves the referential identity passed to each post item.
// Avoids re-rendering all posts on each keystroke.
@@ -697,6 +718,15 @@ export const ComposePost = ({
{!isWebFooterSticky && footer}
</View>
<LanguageNudgeDialog
// TODO: account for threads - will need to thread through API response
postText={thread.posts[0].richtext}
replyToUri={replyTo?.uri}
control={languageNudgeControl}
postAnyway={postAnyway}
discardReply={onClose}
/>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
@@ -997,7 +1027,7 @@ function ComposerTopBar({
shape="default"
size="small"
style={[a.rounded_full, a.py_sm]}
onPress={onPublish}
onPress={() => onPublish()}
disabled={!canPost || isPublishQueued}>
<ButtonText style={[a.text_md]}>
{isReply ? (
+242
View File
@@ -0,0 +1,242 @@
import {useState} from 'react'
import {LayoutAnimation, View} from 'react-native'
import {type AppBskyActorDefs, type RichText} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {urls} from '#/lib/constants'
import {logger} from '#/logger'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed'
export default function LanguageNudgeDialog({
control,
postText,
replyToUri,
postAnyway,
discardReply,
}: {
control: Dialog.DialogControlProps
postText: RichText
replyToUri?: string
postAnyway: () => void
discardReply: () => void
}) {
return (
<Dialog.Outer
control={control}
nativeOptions={{preventExpansion: true}}
webOptions={{alignCenter: true}}>
<Dialog.Handle />
<DialogInner
postText={postText}
replyToUri={replyToUri}
postAnyway={postAnyway}
discardReply={discardReply}
/>
</Dialog.Outer>
)
}
function DialogInner({
postText,
replyToUri,
postAnyway,
discardReply,
}: {
postText: RichText
replyToUri?: string
postAnyway: () => void
discardReply: () => void
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const profile = useProfileQuery({did: currentAccount?.did})
const [feedbackSubmitted, setFeedbackSubmitted] = useState(false)
return (
<Dialog.ScrollableInner
label={_(msg`Are you sure you want to post this?`)}
style={web({maxWidth: 500})}
contentContainerStyle={[a.px_0]}>
<View style={[a.flex_1, a.gap_md, a.align_center, a.py_lg, a.px_xl]}>
<View
style={[
{
width: 64,
height: 64,
backgroundColor: t.palette.primary_50,
},
a.rounded_full,
a.justify_center,
a.align_center,
]}
aria-hidden={true}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants">
{/* No need to translate, it's decorative */}
<Text
style={[
a.leading_tight,
a.text_xl,
a.font_bold,
a.text_center,
{color: t.palette.primary_500},
]}
maxFontSizeMultiplier={1}
minimumFontScale={1}>
@#!
</Text>
</View>
<Text style={[a.text_2xl, a.leading_snug, a.font_bold, a.text_center]}>
<Trans>Are you sure you want to post this?</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
t.atoms.text_contrast_high,
]}>
{replyToUri ? (
<Trans>
Your reply includes language that might go against our{' '}
<InlineLinkText
label={_(msg`Community guidelines`)}
to={urls.website.support.communityGuidelines}
style={[a.text_md, a.leading_snug]}>
community guidelines
</InlineLinkText>
.
</Trans>
) : (
<Trans>
Your post includes language that might go against our{' '}
<InlineLinkText
label={_(msg`Community guidelines`)}
to={urls.website.support.communityGuidelines}
style={[a.text_md, a.leading_snug]}>
community guidelines
</InlineLinkText>
.
</Trans>
)}
</Text>
{profile.data && (
<View style={[a.w_full, a.pointer_events_none]}>
<QuoteEmbed
quote={{
$type: 'app.bsky.feed.defs#postView',
uri: `at://${profile.data.did}/app.bsky.feed.post/draft`,
cid: '',
author: profile.data as AppBskyActorDefs.ProfileViewBasic,
record: {
$type: 'app.bsky.feed.post',
createdAt: new Date().toISOString(),
text: postText.text,
facets: postText.facets,
},
indexedAt: new Date().toISOString(),
}}
/>
</View>
)}
{feedbackSubmitted ? (
<Text
style={[
a.py_sm,
a.text_sm,
a.leading_snug,
a.text_center,
t.atoms.text_contrast_medium,
a.italic,
]}>
Feedback submitted
</Text>
) : (
<Button
label={_(msg`Did we get this wrong?`)}
onPress={() => {
logger.metric('composer:nudge:feedback', {
replyTo: replyToUri,
text: postText.text,
})
LayoutAnimation.configureNext(
LayoutAnimation.Presets.easeInEaseOut,
)
setFeedbackSubmitted(true)
}}
size="small"
color="secondary"
variant="ghost">
<ButtonText>
<Trans>Did we get this wrong?</Trans>
</ButtonText>
</Button>
)}
</View>
<View
style={[
a.flex_1,
a.pt_xl,
a.gap_md,
a.align_stretch,
a.border_t,
t.atoms.border_contrast_low,
a.px_xl,
]}>
<Button
label={_(msg`Keep editing`)}
onPress={() => control.close()}
size="large"
color="primary"
variant="solid">
<ButtonText>
<Trans>Keep editing</Trans>
</ButtonText>
</Button>
<Button
label={replyToUri ? _(msg`Discard reply`) : _(msg`Discard post`)}
onPress={() => control.close(discardReply)}
size="large"
color="negative"
variant="outline">
<ButtonText>
{replyToUri ? (
<Trans>Discard reply</Trans>
) : (
<Trans>Discard post</Trans>
)}
</ButtonText>
</Button>
<Button
label={_(msg`Reply anyway`)}
onPress={() => control.close(postAnyway)}
size="large"
color="secondary"
variant="solid">
<ButtonText>
{replyToUri ? (
<Trans>Reply anyway</Trans>
) : (
<Trans>Post anyway</Trans>
)}
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}