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({
)}