add composer nudge
This commit is contained in:
@@ -195,6 +195,10 @@ export const EMOJI_REACTION_LIMIT = 5
|
|||||||
|
|
||||||
export const urls = {
|
export const urls = {
|
||||||
website: {
|
website: {
|
||||||
|
support: {
|
||||||
|
communityGuidelines:
|
||||||
|
'https://bsky.social/about/support/community-guidelines',
|
||||||
|
},
|
||||||
blog: {
|
blog: {
|
||||||
initialVerificationAnnouncement: `https://bsky.social/about/blog/04-21-2025-verification`,
|
initialVerificationAnnouncement: `https://bsky.social/about/blog/04-21-2025-verification`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ export type MetricEvents = {
|
|||||||
'composer:gif:open': {}
|
'composer:gif:open': {}
|
||||||
'composer:gif:select': {}
|
'composer:gif:select': {}
|
||||||
|
|
||||||
|
'composer:nudge:feedback': {
|
||||||
|
replyTo?: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
// Data events
|
// Data events
|
||||||
'account:create:begin': {}
|
'account:create:begin': {}
|
||||||
'account:create:success': {
|
'account:create:success': {
|
||||||
|
|||||||
+152
-122
@@ -128,6 +128,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
|||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text as NewText} from '#/components/Typography'
|
import {Text as NewText} from '#/components/Typography'
|
||||||
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
||||||
|
import LanguageNudgeDialog from './LanguageNudge'
|
||||||
import {
|
import {
|
||||||
type ComposerAction,
|
type ComposerAction,
|
||||||
composerReducer,
|
composerReducer,
|
||||||
@@ -376,136 +377,156 @@ export const ComposePost = ({
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressPublish = React.useCallback(async () => {
|
const languageNudgeControl = useDialogControl()
|
||||||
if (isPublishing) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canPost) {
|
const onPressPublish = useCallback(
|
||||||
return
|
async (bypassNudge = false) => {
|
||||||
}
|
if (isPublishing) {
|
||||||
|
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.
|
|
||||||
}
|
}
|
||||||
} 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 (!canPost) {
|
||||||
if (err.includes('not locate record')) {
|
return
|
||||||
err = _(
|
}
|
||||||
msg`We're sorry! The post you are replying to has been deleted.`,
|
|
||||||
|
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)
|
setError('')
|
||||||
return
|
setIsPublishing(true)
|
||||||
} finally {
|
|
||||||
if (postUri) {
|
if (!bypassNudge) {
|
||||||
let index = 0
|
// api call goes here
|
||||||
for (let post of thread.posts) {
|
await new Promise(res => setTimeout(res, 500))
|
||||||
logEvent('post:create', {
|
if (true) {
|
||||||
imageCount:
|
setIsPublishing(false)
|
||||||
post.embed.media?.type === 'images'
|
languageNudgeControl.open()
|
||||||
? post.embed.media.images.length
|
return
|
||||||
: 0,
|
}
|
||||||
isReply: index > 0 || !!replyTo,
|
}
|
||||||
isPartOfThread: thread.posts.length > 1,
|
|
||||||
hasLink: !!post.embed.link,
|
let postUri
|
||||||
hasQuote: !!post.embed.quote,
|
try {
|
||||||
langs: langPrefs.postLanguage,
|
postUri = (
|
||||||
logContext: 'Composer',
|
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.
|
||||||
}
|
}
|
||||||
}
|
} catch (e: any) {
|
||||||
if (thread.posts.length > 1) {
|
logger.error(e, {
|
||||||
logEvent('thread:create', {
|
message: `Composer: create post failed`,
|
||||||
postCount: thread.posts.length,
|
hasImages: thread.posts.some(p => p.embed.media?.type === 'images'),
|
||||||
isReply: !!replyTo,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
let err = cleanError(e.message)
|
||||||
if (postUri && !replyTo) {
|
if (err.includes('not locate record')) {
|
||||||
emitPostCreated()
|
err = _(
|
||||||
}
|
msg`We're sorry! The post you are replying to has been deleted.`,
|
||||||
setLangPrefs.savePostLanguageToHistory()
|
)
|
||||||
if (initQuote) {
|
} else if (e instanceof EmbeddingDisabledError) {
|
||||||
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
|
err = _(msg`This post's author has disabled quote posts.`)
|
||||||
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
|
setError(err)
|
||||||
})
|
setIsPublishing(false)
|
||||||
} else {
|
return
|
||||||
onPost?.(postUri)
|
} finally {
|
||||||
}
|
if (postUri) {
|
||||||
onClose()
|
let index = 0
|
||||||
Toast.show(
|
for (let post of thread.posts) {
|
||||||
thread.posts.length > 1
|
logEvent('post:create', {
|
||||||
? _(msg`Your posts have been published`)
|
imageCount:
|
||||||
: replyTo
|
post.embed.media?.type === 'images'
|
||||||
? _(msg`Your reply has been published`)
|
? post.embed.media.images.length
|
||||||
: _(msg`Your post has been published`),
|
: 0,
|
||||||
)
|
isReply: index > 0 || !!replyTo,
|
||||||
}, [
|
isPartOfThread: thread.posts.length > 1,
|
||||||
_,
|
hasLink: !!post.embed.link,
|
||||||
agent,
|
hasQuote: !!post.embed.quote,
|
||||||
thread,
|
langs: langPrefs.postLanguage,
|
||||||
canPost,
|
logContext: 'Composer',
|
||||||
isPublishing,
|
})
|
||||||
langPrefs.postLanguage,
|
index++
|
||||||
onClose,
|
}
|
||||||
onPost,
|
}
|
||||||
initQuote,
|
if (thread.posts.length > 1) {
|
||||||
replyTo,
|
logEvent('thread:create', {
|
||||||
setLangPrefs,
|
postCount: thread.posts.length,
|
||||||
queryClient,
|
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.
|
// Preserves the referential identity passed to each post item.
|
||||||
// Avoids re-rendering all posts on each keystroke.
|
// Avoids re-rendering all posts on each keystroke.
|
||||||
@@ -697,6 +718,15 @@ export const ComposePost = ({
|
|||||||
{!isWebFooterSticky && footer}
|
{!isWebFooterSticky && footer}
|
||||||
</View>
|
</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
|
<Prompt.Basic
|
||||||
control={discardPromptControl}
|
control={discardPromptControl}
|
||||||
title={_(msg`Discard draft?`)}
|
title={_(msg`Discard draft?`)}
|
||||||
@@ -997,7 +1027,7 @@ function ComposerTopBar({
|
|||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
style={[a.rounded_full, a.py_sm]}
|
style={[a.rounded_full, a.py_sm]}
|
||||||
onPress={onPublish}
|
onPress={() => onPublish()}
|
||||||
disabled={!canPost || isPublishQueued}>
|
disabled={!canPost || isPublishQueued}>
|
||||||
<ButtonText style={[a.text_md]}>
|
<ButtonText style={[a.text_md]}>
|
||||||
{isReply ? (
|
{isReply ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user