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 + + ) : ( + + )} + + + + + + + + + ) +}