diff --git a/assets/misc/neue-char.jpg b/assets/misc/neue-char.jpg new file mode 100644 index 0000000000..bf15ee1d8f Binary files /dev/null and b/assets/misc/neue-char.jpg differ diff --git a/src/components/dialogs/nuxs/NeueChar.tsx b/src/components/dialogs/nuxs/NeueChar.tsx new file mode 100644 index 0000000000..a298d481e8 --- /dev/null +++ b/src/components/dialogs/nuxs/NeueChar.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import {View} from 'react-native' +import {Image} from 'expo-image' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {useNuxDialogContext} from '#/components/dialogs/nuxs' +import {Text} from '#/components/Typography' + +// midnight PDT on April 1st, 2025 +export const EXPIRY = new Date('2025-04-02T07:00:00Z') + +export function isEnabled() { + return new Date() < EXPIRY +} + +export function NeueChar() { + const t = useTheme() + const {_} = useLingui() + const nuxDialogs = useNuxDialogContext() + const control = Dialog.useDialogControl() + const {gtMobile} = useBreakpoints() + const [height, setHeight] = React.useState(0) + + Dialog.useAutoOpen(control, 3e3) + + const onClose = React.useCallback(() => { + nuxDialogs.dismissActiveNux() + }, [nuxDialogs]) + + return ( + + + { + setHeight(e.nativeEvent.layout.height) + }} + style={[ + a.absolute, + a.inset_0, + a.overflow_hidden, + { + backgroundColor: 'black', + bottom: 'auto', + paddingTop: gtMobile ? '42%' : '60%', + borderTopLeftRadius: a.rounded_md.borderRadius, + borderTopRightRadius: a.rounded_md.borderRadius, + }, + ]}> + {_( + + + + + + + New character limit ๐ŸŽ‰ + + + + Weโ€™re listening to your feedback and updating the character count. + Posts are now limited to 299 characters! + + + + + For a limited time โ€” just today ;) + + + {!gtMobile && ( + + + + )} + + + + + + ) +} diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 10cae887ba..08600634ab 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -1,16 +1,17 @@ import React from 'react' -import {AppBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs} from '@atproto/api' import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {Nux, useNuxs, useResetNuxs, useSaveNux} from '#/state/queries/nuxs' import { usePreferencesQuery, - UsePreferencesQueryResponse, + type UsePreferencesQueryResponse, } from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' -import {SessionAccount, useSession} from '#/state/session' +import {type SessionAccount, useSession} from '#/state/session' import {useOnboardingState} from '#/state/shell' +import {isEnabled, NeueChar} from '#/components/dialogs/nuxs/NeueChar' /* * NUXs */ @@ -29,7 +30,12 @@ const queuedNuxs: { currentProfile: AppBskyActorDefs.ProfileViewDetailed preferences: UsePreferencesQueryResponse }) => boolean -}[] = [] +}[] = [ + { + id: Nux.NeueChar, + enabled: isEnabled, + }, +] const Context = React.createContext({ activeNux: undefined, @@ -102,7 +108,7 @@ function Inner({ } React.useEffect(() => { - if (snoozed) return + // if (snoozed) return if (!nuxs) return for (const {id, enabled} of queuedNuxs) { @@ -110,7 +116,7 @@ function Inner({ // check if completed first if (nux && nux.completed) { - continue + // continue } // then check gate (track exposure) @@ -163,6 +169,7 @@ function Inner({ return ( {/*For example, activeNux === Nux.NeueTypography && */} + {activeNux === Nux.NeueChar && } ) } diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index 8166602c8a..c9f4cdbe3c 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -1,18 +1,26 @@ -import zod from 'zod' +import type zod from 'zod' -import {BaseNux} from '#/state/queries/nuxs/types' +import {type BaseNux} from '#/state/queries/nuxs/types' export enum Nux { NeueTypography = 'NeueTypography', + NeueChar = 'NeueChar', } export const nuxNames = new Set(Object.values(Nux)) -export type AppNux = BaseNux<{ - id: Nux.NeueTypography - data: undefined -}> +export type AppNux = BaseNux< + | { + id: Nux.NeueTypography + data: undefined + } + | { + id: Nux.NeueChar + data: undefined + } +> export const NuxSchemas: Record | undefined> = { [Nux.NeueTypography]: undefined, + [Nux.NeueChar]: undefined, } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e8fe1a7ce7..36a37a5317 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -12,17 +12,17 @@ import { BackHandler, Keyboard, KeyboardAvoidingView, - LayoutChangeEvent, + type LayoutChangeEvent, ScrollView, - StyleProp, + type StyleProp, StyleSheet, View, - ViewStyle, + type ViewStyle, } from 'react-native' // @ts-expect-error no type definition import ProgressCircle from 'react-native-progress/Circle' import Animated, { - AnimatedRef, + type AnimatedRef, Easing, FadeIn, FadeOut, @@ -41,12 +41,12 @@ import Animated, { ZoomOut, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {ImagePickerAsset} from 'expo-image-picker' +import {type ImagePickerAsset} from 'expo-image-picker' import { AppBskyFeedDefs, - AppBskyFeedGetPostThread, - BskyAgent, - RichText, + type AppBskyFeedGetPostThread, + type BskyAgent, + type RichText, } from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' @@ -59,7 +59,7 @@ import {until} from '#/lib/async/until' import { MAX_GRAPHEME_LENGTH, SUPPORTED_MIME_TYPES, - SupportedMimeTypes, + type SupportedMimeTypes, } from '#/lib/constants' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useEmail} from '#/lib/hooks/useEmail' @@ -75,7 +75,7 @@ import {logger} from '#/logger' import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {useDialogStateControlContext} from '#/state/dialogs' import {emitPostCreated} from '#/state/events' -import {ComposerImage, pasteImage} from '#/state/gallery' +import {type ComposerImage, pasteImage} from '#/state/gallery' import {useModalControls} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { @@ -85,10 +85,10 @@ import { } from '#/state/preferences/languages' import {usePreferencesQuery} from '#/state/queries/preferences' import {useProfileQuery} from '#/state/queries/profile' -import {Gif} from '#/state/queries/tenor' +import {type Gif} from '#/state/queries/tenor' import {useAgent, useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' -import {ComposerOpts} from '#/state/shell/composer' +import {type ComposerOpts} from '#/state/shell/composer' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo' import { @@ -105,7 +105,10 @@ import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn' import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage' // TODO: Prevent naming components that coincide with RN primitives // due to linting false positives -import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput' +import { + TextInput, + type TextInputRef, +} from '#/view/com/composer/text-input/TextInput' import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' @@ -118,6 +121,7 @@ import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' +import {isEnabled} from '#/components/dialogs/nuxs/NeueChar' import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' @@ -126,16 +130,21 @@ import * as Prompt from '#/components/Prompt' import {Text as NewText} from '#/components/Typography' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import { - ComposerAction, + type ComposerAction, composerReducer, createComposerState, - EmbedDraft, + type EmbedDraft, MAX_IMAGES, - PostAction, - PostDraft, - ThreadDraft, + type PostAction, + type PostDraft, + type ThreadDraft, } from './state/composer' -import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video' +import { + NO_VIDEO, + type NoVideoState, + processVideo, + type VideoState, +} from './state/video' import {getVideoMetadata} from './videos/pickVideo' import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' @@ -171,6 +180,7 @@ export const ComposePost = ({ const {closeAllDialogs} = useDialogStateControlContext() const {closeAllModals} = useModalControls() const {data: preferences} = usePreferencesQuery() + const MAX_GRAPHEMES = isEnabled() ? 299 : MAX_GRAPHEME_LENGTH const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isPublishing, setIsPublishing] = useState(false) @@ -360,7 +370,7 @@ export const ComposePost = ({ !missingAltError && thread.posts.every( post => - post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH && + post.shortenedGraphemeLength <= MAX_GRAPHEMES && !isEmptyPost(post) && !( post.embed.media?.type === 'video' && @@ -605,6 +615,7 @@ export const ComposePost = ({ type: 'add_post', }) }} + maxGraphemeLength={MAX_GRAPHEMES} /> ) @@ -678,6 +689,7 @@ export const ComposePost = ({ onClearVideo={clearVideo} onPublish={onComposerPostPublish} onError={setError} + maxGraphemeLength={MAX_GRAPHEMES} /> {isWebFooterSticky && post.id === activePost.id && ( {footer} @@ -715,6 +727,7 @@ let ComposerPost = React.memo(function ComposerPost({ onSelectVideo, onError, onPublish, + maxGraphemeLength, }: { post: PostDraft dispatch: (action: ComposerAction) => void @@ -729,6 +742,7 @@ let ComposerPost = React.memo(function ComposerPost({ onSelectVideo: (postId: string, asset: ImagePickerAsset) => void onError: (error: string) => void onPublish: (richtext: RichText) => void + maxGraphemeLength: number }) { const {currentAccount} = useSession() const currentDid = currentAccount!.did @@ -831,7 +845,7 @@ let ComposerPost = React.memo(function ComposerPost({ accessible={true} accessibilityLabel={_(msg`Write post`)} accessibilityHint={_( - msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, + msg`Compose posts up to ${maxGraphemeLength} characters in length`, )} /> @@ -1170,6 +1184,7 @@ function ComposerFooter({ onError, onSelectVideo, onAddPost, + maxGraphemeLength, }: { post: PostDraft dispatch: (action: PostAction) => void @@ -1178,6 +1193,7 @@ function ComposerFooter({ onError: (error: string) => void onSelectVideo: (postId: string, asset: ImagePickerAsset) => void onAddPost: () => void + maxGraphemeLength: number }) { const t = useTheme() const {_} = useLingui() @@ -1270,6 +1286,7 @@ function ComposerFooter({ )}