diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index bb98f9fc8e..bc6023fafc 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -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`,
},
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index 42b0d6ef35..332993b60d 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -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': {
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 4384783dcc..b247a6f123 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -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}
+
+
onPublish()}
disabled={!canPost || isPublishQueued}>
{isReply ? (
diff --git a/src/view/com/composer/LanguageNudge.tsx b/src/view/com/composer/LanguageNudge.tsx
new file mode 100644
index 0000000000..1cad7954bf
--- /dev/null
+++ b/src/view/com/composer/LanguageNudge.tsx
@@ -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 (
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+ {/* No need to translate, it's decorative */}
+
+ @#!
+
+
+
+
+ Are you sure you want to post this?
+
+
+
+ {replyToUri ? (
+
+ Your reply includes language that might go against our{' '}
+
+ community guidelines
+
+ .
+
+ ) : (
+
+ Your post includes language that might go against our{' '}
+
+ community guidelines
+
+ .
+
+ )}
+
+
+ {profile.data && (
+
+
+
+ )}
+
+ {feedbackSubmitted ? (
+
+ Feedback submitted
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}