Thread composer UI (#6050)
* Basic adding of posts * Switch active post on focus * Conditionally show plus button * Insert posts midthread * Track active/inactive post * Delete posts in a thread * Focus after deletion * Tweak empty post detection * Mix height for active only * Move toolbar with post on web * Fix footer positioning * Post All button * Fix reply to positioning * Improve memoization * Improve memoization for clearVideo * Remove unnecessary argument * Add some manual memoization to fix re-renders * Scroll to bottom on add new * Fix opacity on Android * Add backdrop * Fix videos * Check alt for video too * Clear pending publish on error * Fork alt message by type * Separate placeholder for next posts * Limit hitslop to avoid clashes
This commit is contained in:
+1
-1
@@ -68,7 +68,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/react-native-fontawesome": "^0.3.2",
|
||||
"@haileyok/bluesky-video": "0.2.3",
|
||||
"@haileyok/bluesky-video": "0.2.4",
|
||||
"@ipld/dag-cbor": "^9.2.0",
|
||||
"@lingui/react": "^4.5.0",
|
||||
"@mattermost/react-native-paste-input": "^0.7.1",
|
||||
|
||||
@@ -95,6 +95,7 @@ export const HITSLOP_10 = createHitslop(10)
|
||||
export const HITSLOP_20 = createHitslop(20)
|
||||
export const HITSLOP_30 = createHitslop(30)
|
||||
export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10}
|
||||
export const LANG_DROPDOWN_HITSLOP = {top: 10, bottom: 10, left: 4, right: 4}
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
export const MAX_POST_LINES = 25
|
||||
|
||||
|
||||
+346
-204
@@ -28,6 +28,9 @@ import Animated, {
|
||||
interpolateColor,
|
||||
LayoutAnimationConfig,
|
||||
LinearTransition,
|
||||
runOnUI,
|
||||
scrollTo,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
@@ -167,9 +170,10 @@ export const ComposePost = ({
|
||||
createComposerState,
|
||||
)
|
||||
|
||||
// TODO: Display drafts for other posts in the thread.
|
||||
const thread = composerState.thread
|
||||
const activePost = thread.posts[composerState.activePostIndex]
|
||||
const nextPost: PostDraft | undefined =
|
||||
thread.posts[composerState.activePostIndex + 1]
|
||||
const dispatch = useCallback(
|
||||
(postAction: PostAction) => {
|
||||
composerDispatch({
|
||||
@@ -226,20 +230,15 @@ export const ComposePost = ({
|
||||
|
||||
const clearVideo = React.useCallback(
|
||||
(postId: string) => {
|
||||
const post = thread.posts.find(p => p.id === postId)
|
||||
const postMedia = post?.embed.media
|
||||
if (postMedia?.type === 'video') {
|
||||
postMedia.video.abortController.abort()
|
||||
composerDispatch({
|
||||
type: 'update_post',
|
||||
postId: postId,
|
||||
postAction: {
|
||||
type: 'embed_remove_video',
|
||||
},
|
||||
})
|
||||
}
|
||||
composerDispatch({
|
||||
type: 'update_post',
|
||||
postId: postId,
|
||||
postAction: {
|
||||
type: 'embed_remove_video',
|
||||
},
|
||||
})
|
||||
},
|
||||
[thread, composerDispatch],
|
||||
[composerDispatch],
|
||||
)
|
||||
|
||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||
@@ -297,180 +296,189 @@ export const ComposePost = ({
|
||||
}
|
||||
}, [onPressCancel, closeAllDialogs, closeAllModals])
|
||||
|
||||
const isAltTextRequiredAndMissing = useMemo(() => {
|
||||
const missingAltError = useMemo(() => {
|
||||
if (!requireAltTextEnabled) {
|
||||
return false
|
||||
return
|
||||
}
|
||||
return thread.posts.some(post => {
|
||||
const media = post.embed.media
|
||||
for (let i = 0; i < thread.posts.length; i++) {
|
||||
const media = thread.posts[i].embed.media
|
||||
if (media) {
|
||||
if (media.type === 'images' && media.images.some(img => !img.alt)) {
|
||||
return true
|
||||
return _(msg`One or more images is missing alt text.`)
|
||||
}
|
||||
if (media.type === 'gif' && !media.alt) {
|
||||
return true
|
||||
return _(msg`One or more GIFs is missing alt text.`)
|
||||
}
|
||||
if (
|
||||
media.type === 'video' &&
|
||||
media.video.status !== 'error' &&
|
||||
!media.video.altText
|
||||
) {
|
||||
return _(msg`One or more videos is missing alt text.`)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [thread, requireAltTextEnabled])
|
||||
}
|
||||
}, [thread, requireAltTextEnabled, _])
|
||||
|
||||
const canPost =
|
||||
!isAltTextRequiredAndMissing &&
|
||||
!missingAltError &&
|
||||
thread.posts.every(
|
||||
post =>
|
||||
post.shortenedGraphemeLength <= MAX_GRAPHEME_LENGTH &&
|
||||
!(
|
||||
post.richtext.text.trim().length === 0 &&
|
||||
!post.embed.link &&
|
||||
!post.embed.media &&
|
||||
!post.embed.quote
|
||||
) &&
|
||||
!isEmptyPost(post) &&
|
||||
!(
|
||||
post.embed.media?.type === 'video' &&
|
||||
post.embed.media.video.status === 'error'
|
||||
),
|
||||
)
|
||||
|
||||
const onPressPublish = React.useCallback(
|
||||
async (finishedUploading: boolean) => {
|
||||
if (isPublishing) {
|
||||
return
|
||||
}
|
||||
const onPressPublish = React.useCallback(async () => {
|
||||
if (isPublishing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!canPost) {
|
||||
return
|
||||
}
|
||||
if (!canPost) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!finishedUploading &&
|
||||
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 (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.`)
|
||||
}
|
||||
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,
|
||||
hasLink: !!post.embed.link,
|
||||
hasQuote: !!post.embed.quote,
|
||||
langs: langPrefs.postLanguage,
|
||||
logContext: 'Composer',
|
||||
})
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
replyTo
|
||||
? _(msg`Your reply has been published`)
|
||||
: _(msg`Your post has been published`),
|
||||
if (
|
||||
thread.posts.some(
|
||||
post =>
|
||||
post.embed.media?.type === 'video' &&
|
||||
post.embed.media.video.asset &&
|
||||
post.embed.media.video.status !== 'done',
|
||||
)
|
||||
},
|
||||
[
|
||||
_,
|
||||
agent,
|
||||
thread,
|
||||
canPost,
|
||||
isPublishing,
|
||||
langPrefs.postLanguage,
|
||||
onClose,
|
||||
onPost,
|
||||
initQuote,
|
||||
replyTo,
|
||||
setLangPrefs,
|
||||
queryClient,
|
||||
],
|
||||
)
|
||||
) {
|
||||
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 (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.`)
|
||||
}
|
||||
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,
|
||||
hasLink: !!post.embed.link,
|
||||
hasQuote: !!post.embed.quote,
|
||||
langs: langPrefs.postLanguage,
|
||||
logContext: 'Composer',
|
||||
})
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
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,
|
||||
])
|
||||
|
||||
// Preserves the referential identity passed to each post item.
|
||||
// Avoids re-rendering all posts on each keystroke.
|
||||
const onComposerPostPublish = useNonReactiveCallback(() => {
|
||||
onPressPublish()
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (publishOnUpload) {
|
||||
let erroredVideos = 0
|
||||
let uploadingVideos = 0
|
||||
for (let post of thread.posts) {
|
||||
if (post.embed.media?.type === 'video') {
|
||||
const video = post.embed.media.video
|
||||
if (!video.pendingPublish) {
|
||||
if (video.status === 'error') {
|
||||
erroredVideos++
|
||||
} else if (video.status !== 'done') {
|
||||
uploadingVideos++
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uploadingVideos === 0) {
|
||||
if (erroredVideos > 0) {
|
||||
setPublishOnUpload(false)
|
||||
onPressPublish(true)
|
||||
} else if (uploadingVideos === 0) {
|
||||
setPublishOnUpload(false)
|
||||
onPressPublish()
|
||||
}
|
||||
}
|
||||
}, [thread.posts, onPressPublish, publishOnUpload])
|
||||
@@ -495,7 +503,16 @@ export const ComposePost = ({
|
||||
openEmojiPicker?.(textInput.current?.getCursorPosition())
|
||||
}, [openEmojiPicker])
|
||||
|
||||
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
|
||||
useEffect(() => {
|
||||
if (composerState.mutableNeedsFocusActive) {
|
||||
composerState.mutableNeedsFocusActive = false
|
||||
textInput.current?.focus()
|
||||
}
|
||||
}, [composerState])
|
||||
|
||||
const {
|
||||
contentHeight,
|
||||
scrollHandler,
|
||||
onScrollViewContentSizeChange,
|
||||
onScrollViewLayout,
|
||||
@@ -503,8 +520,44 @@ export const ComposePost = ({
|
||||
bottomBarAnimatedStyle,
|
||||
} = useAnimatedBorders()
|
||||
|
||||
useEffect(() => {
|
||||
if (composerState.mutableNeedsScrollToBottom) {
|
||||
composerState.mutableNeedsScrollToBottom = false
|
||||
runOnUI(scrollTo)(scrollViewRef, 0, contentHeight.value, true)
|
||||
}
|
||||
}, [composerState, scrollViewRef, contentHeight])
|
||||
|
||||
const keyboardVerticalOffset = useKeyboardVerticalOffset()
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<SuggestedLanguage text={activePost.richtext.text} />
|
||||
<ComposerPills
|
||||
isReply={!!replyTo}
|
||||
post={activePost}
|
||||
thread={composerState.thread}
|
||||
dispatch={composerDispatch}
|
||||
bottomBarAnimatedStyle={bottomBarAnimatedStyle}
|
||||
/>
|
||||
<ComposerFooter
|
||||
post={activePost}
|
||||
dispatch={dispatch}
|
||||
showAddButton={
|
||||
!isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost))
|
||||
}
|
||||
onError={setError}
|
||||
onEmojiButtonPress={onEmojiButtonPress}
|
||||
onSelectVideo={selectVideo}
|
||||
onAddPost={() => {
|
||||
composerDispatch({
|
||||
type: 'add_post',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
const isFooterSticky = !isNative && thread.posts.length > 1
|
||||
return (
|
||||
<BottomSheetPortalProvider>
|
||||
<KeyboardAvoidingView
|
||||
@@ -521,11 +574,12 @@ export const ComposePost = ({
|
||||
isReply={!!replyTo}
|
||||
isPublishQueued={publishOnUpload}
|
||||
isPublishing={isPublishing}
|
||||
isThread={thread.posts.length > 1}
|
||||
publishingStage={publishingStage}
|
||||
topBarAnimatedStyle={topBarAnimatedStyle}
|
||||
onCancel={onPressCancel}
|
||||
onPublish={() => onPressPublish(false)}>
|
||||
{isAltTextRequiredAndMissing && <AltTextReminder />}
|
||||
onPublish={onPressPublish}>
|
||||
{missingAltError && <AltTextReminder error={missingAltError} />}
|
||||
<ErrorBanner
|
||||
error={error}
|
||||
videoState={erroredVideo}
|
||||
@@ -539,6 +593,7 @@ export const ComposePost = ({
|
||||
</ComposerTopBar>
|
||||
|
||||
<Animated.ScrollView
|
||||
ref={scrollViewRef}
|
||||
layout={native(LinearTransition)}
|
||||
onScroll={scrollHandler}
|
||||
style={styles.scrollView}
|
||||
@@ -546,37 +601,27 @@ export const ComposePost = ({
|
||||
onContentSizeChange={onScrollViewContentSizeChange}
|
||||
onLayout={onScrollViewLayout}>
|
||||
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
|
||||
<ComposerPost
|
||||
key={activePost.id}
|
||||
post={activePost}
|
||||
dispatch={composerDispatch}
|
||||
textInput={textInput}
|
||||
isReply={!!replyTo}
|
||||
canRemoveQuote={!initQuote}
|
||||
onSelectVideo={asset => selectVideo(activePost.id, asset)}
|
||||
onClearVideo={() => clearVideo(activePost.id)}
|
||||
onPublish={() => onPressPublish(false)}
|
||||
onError={setError}
|
||||
/>
|
||||
{thread.posts.map((post, index) => (
|
||||
<React.Fragment key={post.id}>
|
||||
<ComposerPost
|
||||
post={post}
|
||||
dispatch={composerDispatch}
|
||||
textInput={post.id === activePost.id ? textInput : null}
|
||||
isFirstPost={index === 0}
|
||||
isReply={index > 0 || !!replyTo}
|
||||
isActive={post.id === activePost.id}
|
||||
canRemovePost={thread.posts.length > 1}
|
||||
canRemoveQuote={index > 0 || !initQuote}
|
||||
onSelectVideo={selectVideo}
|
||||
onClearVideo={clearVideo}
|
||||
onPublish={onComposerPostPublish}
|
||||
onError={setError}
|
||||
/>
|
||||
{isFooterSticky && post.id === activePost.id && footer}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Animated.ScrollView>
|
||||
|
||||
<React.Fragment key={activePost.id}>
|
||||
<SuggestedLanguage text={activePost.richtext.text} />
|
||||
<ComposerPills
|
||||
isReply={!!replyTo}
|
||||
post={activePost}
|
||||
thread={composerState.thread}
|
||||
dispatch={composerDispatch}
|
||||
bottomBarAnimatedStyle={bottomBarAnimatedStyle}
|
||||
/>
|
||||
<ComposerFooter
|
||||
post={activePost}
|
||||
dispatch={dispatch}
|
||||
onError={setError}
|
||||
onEmojiButtonPress={onEmojiButtonPress}
|
||||
onSelectVideo={asset => selectVideo(activePost.id, asset)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
{!isFooterSticky && footer}
|
||||
</View>
|
||||
|
||||
<Prompt.Basic
|
||||
@@ -592,11 +637,14 @@ export const ComposePost = ({
|
||||
)
|
||||
}
|
||||
|
||||
function ComposerPost({
|
||||
let ComposerPost = React.memo(function ComposerPost({
|
||||
post,
|
||||
dispatch,
|
||||
textInput,
|
||||
isActive,
|
||||
isReply,
|
||||
isFirstPost,
|
||||
canRemovePost,
|
||||
canRemoveQuote,
|
||||
onClearVideo,
|
||||
onSelectVideo,
|
||||
@@ -606,10 +654,13 @@ function ComposerPost({
|
||||
post: PostDraft
|
||||
dispatch: (action: ComposerAction) => void
|
||||
textInput: React.Ref<TextInputRef>
|
||||
isActive: boolean
|
||||
isReply: boolean
|
||||
isFirstPost: boolean
|
||||
canRemovePost: boolean
|
||||
canRemoveQuote: boolean
|
||||
onClearVideo: () => void
|
||||
onSelectVideo: (asset: ImagePickerAsset) => void
|
||||
onClearVideo: (postId: string) => void
|
||||
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
|
||||
onError: (error: string) => void
|
||||
onPublish: (richtext: RichText) => void
|
||||
}) {
|
||||
@@ -619,10 +670,13 @@ function ComposerPost({
|
||||
const {data: currentProfile} = useProfileQuery({did: currentDid})
|
||||
const richtext = post.richtext
|
||||
const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media
|
||||
const forceMinHeight = isWeb && isTextOnly
|
||||
const forceMinHeight = isWeb && isTextOnly && isActive
|
||||
const selectTextInputPlaceholder = isReply
|
||||
? _(msg`Write your reply`)
|
||||
? isFirstPost
|
||||
? _(msg`Write your reply`)
|
||||
: _(msg`Add another post`)
|
||||
: _(msg`What's up?`)
|
||||
const discardPromptControl = Prompt.usePromptControl()
|
||||
|
||||
const dispatchPost = useCallback(
|
||||
(action: PostAction) => {
|
||||
@@ -655,17 +709,17 @@ function ComposerPost({
|
||||
const onPhotoPasted = useCallback(
|
||||
async (uri: string) => {
|
||||
if (uri.startsWith('data:video/')) {
|
||||
onSelectVideo({uri, type: 'video', height: 0, width: 0})
|
||||
onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0})
|
||||
} else {
|
||||
const res = await pasteImage(uri)
|
||||
onImageAdd([res])
|
||||
}
|
||||
},
|
||||
[onSelectVideo, onImageAdd],
|
||||
[post.id, onSelectVideo, onImageAdd],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={[styles.post, !isActive && styles.inactivePost]}>
|
||||
<View
|
||||
style={[
|
||||
styles.textInputLayout,
|
||||
@@ -685,6 +739,12 @@ function ComposerPost({
|
||||
setRichText={rt => {
|
||||
dispatchPost({type: 'update_richtext', richtext: rt})
|
||||
}}
|
||||
onFocus={() => {
|
||||
dispatch({
|
||||
type: 'focus_post',
|
||||
postId: post.id,
|
||||
})
|
||||
}}
|
||||
onPhotoPasted={onPhotoPasted}
|
||||
onNewLink={onNewLink}
|
||||
onError={onError}
|
||||
@@ -697,21 +757,65 @@ function ComposerPost({
|
||||
/>
|
||||
</View>
|
||||
|
||||
{canRemovePost && isActive && (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Delete post`)}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
style={[a.absolute, {top: 0, right: 0}]}
|
||||
onPress={() => {
|
||||
if (
|
||||
post.shortenedGraphemeLength > 0 ||
|
||||
post.embed.media ||
|
||||
post.embed.link ||
|
||||
post.embed.quote
|
||||
) {
|
||||
discardPromptControl.open()
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'remove_post',
|
||||
postId: post.id,
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
<Prompt.Basic
|
||||
control={discardPromptControl}
|
||||
title={_(msg`Discard post?`)}
|
||||
description={_(msg`Are you sure you'd like to discard this post?`)}
|
||||
onConfirm={() => {
|
||||
dispatch({
|
||||
type: 'remove_post',
|
||||
postId: post.id,
|
||||
})
|
||||
}}
|
||||
confirmButtonCta={_(msg`Discard`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ComposerEmbeds
|
||||
canRemoveQuote={canRemoveQuote}
|
||||
embed={post.embed}
|
||||
dispatch={dispatchPost}
|
||||
clearVideo={onClearVideo}
|
||||
clearVideo={() => onClearVideo(post.id)}
|
||||
isActivePost={isActive}
|
||||
/>
|
||||
</>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function ComposerTopBar({
|
||||
canPost,
|
||||
isReply,
|
||||
isPublishQueued,
|
||||
isPublishing,
|
||||
isThread,
|
||||
publishingStage,
|
||||
onCancel,
|
||||
onPublish,
|
||||
@@ -723,6 +827,7 @@ function ComposerTopBar({
|
||||
canPost: boolean
|
||||
isReply: boolean
|
||||
isPublishQueued: boolean
|
||||
isThread: boolean
|
||||
onCancel: () => void
|
||||
onPublish: () => void
|
||||
topBarAnimatedStyle: StyleProp<ViewStyle>
|
||||
@@ -769,6 +874,8 @@ function ComposerTopBar({
|
||||
<ButtonText style={[a.text_md]}>
|
||||
{isReply ? (
|
||||
<Trans context="action">Reply</Trans>
|
||||
) : isThread ? (
|
||||
<Trans context="action">Post All</Trans>
|
||||
) : (
|
||||
<Trans context="action">Post</Trans>
|
||||
)}
|
||||
@@ -781,7 +888,7 @@ function ComposerTopBar({
|
||||
)
|
||||
}
|
||||
|
||||
function AltTextReminder() {
|
||||
function AltTextReminder({error}: {error: string}) {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[styles.reminderLine, pal.viewLight]}>
|
||||
@@ -792,9 +899,7 @@ function AltTextReminder() {
|
||||
size={10}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[pal.text, a.flex_1]}>
|
||||
<Trans>One or more images is missing alt text.</Trans>
|
||||
</Text>
|
||||
<Text style={[pal.text, a.flex_1]}>{error}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -804,11 +909,13 @@ function ComposerEmbeds({
|
||||
dispatch,
|
||||
clearVideo,
|
||||
canRemoveQuote,
|
||||
isActivePost,
|
||||
}: {
|
||||
embed: EmbedDraft
|
||||
dispatch: (action: PostAction) => void
|
||||
clearVideo: () => void
|
||||
canRemoveQuote: boolean
|
||||
isActivePost: boolean
|
||||
}) {
|
||||
const video = embed.media?.type === 'video' ? embed.media.video : null
|
||||
return (
|
||||
@@ -860,6 +967,7 @@ function ComposerEmbeds({
|
||||
<VideoPreview
|
||||
asset={video.asset}
|
||||
video={video.video}
|
||||
isActivePost={isActivePost}
|
||||
setDimensions={(width: number, height: number) => {
|
||||
dispatch({
|
||||
type: 'embed_update_video',
|
||||
@@ -988,15 +1096,19 @@ function ComposerPills({
|
||||
function ComposerFooter({
|
||||
post,
|
||||
dispatch,
|
||||
showAddButton,
|
||||
onEmojiButtonPress,
|
||||
onError,
|
||||
onSelectVideo,
|
||||
onAddPost,
|
||||
}: {
|
||||
post: PostDraft
|
||||
dispatch: (action: PostAction) => void
|
||||
showAddButton: boolean
|
||||
onEmojiButtonPress: () => void
|
||||
onError: (error: string) => void
|
||||
onSelectVideo: (asset: ImagePickerAsset) => void
|
||||
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
|
||||
onAddPost: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -1047,7 +1159,7 @@ function ComposerFooter({
|
||||
onAdd={onImageAdd}
|
||||
/>
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={onSelectVideo}
|
||||
onSelectVideo={asset => onSelectVideo(post.id, asset)}
|
||||
disabled={!!media}
|
||||
setError={onError}
|
||||
/>
|
||||
@@ -1072,6 +1184,21 @@ function ComposerFooter({
|
||||
)}
|
||||
</View>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
{showAddButton && (
|
||||
<Button
|
||||
label={_(msg`Add new post`)}
|
||||
onPress={onAddPost}
|
||||
style={[a.p_sm, a.m_2xs]}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary">
|
||||
<FontAwesomeIcon
|
||||
icon="add"
|
||||
size={20}
|
||||
color={t.palette.primary_500}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<SelectLangBtn />
|
||||
<CharProgress
|
||||
count={post.shortenedGraphemeLength}
|
||||
@@ -1179,6 +1306,7 @@ function useAnimatedBorders() {
|
||||
})
|
||||
|
||||
return {
|
||||
contentHeight,
|
||||
scrollHandler,
|
||||
onScrollViewContentSizeChange,
|
||||
onScrollViewLayout,
|
||||
@@ -1217,6 +1345,15 @@ async function whenAppViewReady(
|
||||
)
|
||||
}
|
||||
|
||||
function isEmptyPost(post: PostDraft) {
|
||||
return (
|
||||
post.richtext.text.trim().length === 0 &&
|
||||
!post.embed.media &&
|
||||
!post.embed.link &&
|
||||
!post.embed.quote
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topbarInner: {
|
||||
flexDirection: 'row',
|
||||
@@ -1261,9 +1398,14 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
marginRight: 5,
|
||||
},
|
||||
post: {
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
inactivePost: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
textInputLayout: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -204,6 +204,7 @@ const styles = StyleSheet.create({
|
||||
paddingTop: 4,
|
||||
paddingBottom: 16,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 16,
|
||||
},
|
||||
replyToPost: {
|
||||
flex: 1,
|
||||
|
||||
@@ -159,7 +159,10 @@ const GalleryItem = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={imageStyle}>
|
||||
<View
|
||||
style={imageStyle}
|
||||
// Fixes ALT and icons appearing with half opacity when the post is inactive
|
||||
renderToHardwareTextureAndroid>
|
||||
<TouchableOpacity
|
||||
testID="altTextButton"
|
||||
accessibilityRole="button"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {LANG_DROPDOWN_HITSLOP} from '#/lib/constants'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
@@ -102,6 +103,7 @@ export function SelectLangBtn() {
|
||||
items={items}
|
||||
openUpwards
|
||||
style={styles.button}
|
||||
hitSlop={LANG_DROPDOWN_HITSLOP}
|
||||
accessibilityLabel={_(msg`Language selection`)}
|
||||
accessibilityHint="">
|
||||
{postLanguagesPref.length > 0 ? (
|
||||
@@ -121,7 +123,7 @@ export function SelectLangBtn() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
paddingHorizontal: 15,
|
||||
marginHorizontal: 15,
|
||||
},
|
||||
label: {
|
||||
maxWidth: 100,
|
||||
|
||||
@@ -85,7 +85,9 @@ export type ThreadDraft = {
|
||||
|
||||
export type ComposerState = {
|
||||
thread: ThreadDraft
|
||||
activePostIndex: number // TODO: Add actions to update this.
|
||||
activePostIndex: number
|
||||
mutableNeedsFocusActive: boolean
|
||||
mutableNeedsScrollToBottom: boolean
|
||||
}
|
||||
|
||||
export type ComposerAction =
|
||||
@@ -96,6 +98,17 @@ export type ComposerAction =
|
||||
postId: string
|
||||
postAction: PostAction
|
||||
}
|
||||
| {
|
||||
type: 'add_post'
|
||||
}
|
||||
| {
|
||||
type: 'remove_post'
|
||||
postId: string
|
||||
}
|
||||
| {
|
||||
type: 'focus_post'
|
||||
postId: string
|
||||
}
|
||||
|
||||
export const MAX_IMAGES = 4
|
||||
|
||||
@@ -142,6 +155,69 @@ export function composerReducer(
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'add_post': {
|
||||
const activePostIndex = state.activePostIndex
|
||||
const isAtTheEnd = activePostIndex === state.thread.posts.length - 1
|
||||
const nextPosts = [...state.thread.posts]
|
||||
nextPosts.splice(activePostIndex + 1, 0, {
|
||||
id: nanoid(),
|
||||
richtext: new RichText({text: ''}),
|
||||
shortenedGraphemeLength: 0,
|
||||
labels: [],
|
||||
embed: {
|
||||
quote: undefined,
|
||||
media: undefined,
|
||||
link: undefined,
|
||||
},
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
mutableNeedsScrollToBottom: isAtTheEnd,
|
||||
thread: {
|
||||
...state.thread,
|
||||
posts: nextPosts,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'remove_post': {
|
||||
if (state.thread.posts.length < 2) {
|
||||
return state
|
||||
}
|
||||
let nextActivePostIndex = state.activePostIndex
|
||||
const indexToRemove = state.thread.posts.findIndex(
|
||||
p => p.id === action.postId,
|
||||
)
|
||||
let nextPosts = [...state.thread.posts]
|
||||
if (indexToRemove !== -1) {
|
||||
const postToRemove = state.thread.posts[indexToRemove]
|
||||
if (postToRemove.embed.media?.type === 'video') {
|
||||
postToRemove.embed.media.video.abortController.abort()
|
||||
}
|
||||
nextPosts.splice(indexToRemove, 1)
|
||||
nextActivePostIndex = Math.max(0, indexToRemove - 1)
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
activePostIndex: nextActivePostIndex,
|
||||
mutableNeedsFocusActive: true,
|
||||
thread: {
|
||||
...state.thread,
|
||||
posts: nextPosts,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'focus_post': {
|
||||
const nextActivePostIndex = state.thread.posts.findIndex(
|
||||
p => p.id === action.postId,
|
||||
)
|
||||
if (nextActivePostIndex === -1) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
activePostIndex: nextActivePostIndex,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +351,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
||||
const prevMedia = state.embed.media
|
||||
let nextMedia = prevMedia
|
||||
if (prevMedia?.type === 'video') {
|
||||
prevMedia.video.abortController.abort()
|
||||
nextMedia = undefined
|
||||
}
|
||||
let nextLabels = state.labels
|
||||
@@ -436,6 +513,8 @@ export function createComposerState({
|
||||
})
|
||||
return {
|
||||
activePostIndex: 0,
|
||||
mutableNeedsFocusActive: false,
|
||||
mutableNeedsScrollToBottom: false,
|
||||
thread: {
|
||||
posts: [
|
||||
{
|
||||
|
||||
@@ -47,6 +47,7 @@ interface TextInputProps {
|
||||
onPressPublish: (richtext: RichText) => void
|
||||
onNewLink: (uri: string) => void
|
||||
onError: (err: string) => void
|
||||
onFocus: () => void
|
||||
}
|
||||
|
||||
export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
@@ -58,6 +59,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
onPhotoPasted,
|
||||
onPressPublish,
|
||||
onNewLink,
|
||||
onFocus,
|
||||
}: // onError, TODO
|
||||
TextInputProps,
|
||||
ref,
|
||||
@@ -149,6 +151,9 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
onFocus() {
|
||||
onFocus?.()
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: modeClass,
|
||||
@@ -244,8 +249,12 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
}, [onEmojiInserted])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {}, // TODO
|
||||
blur: () => {}, // TODO
|
||||
focus: () => {
|
||||
editor?.chain().focus()
|
||||
},
|
||||
blur: () => {
|
||||
editor?.chain().blur()
|
||||
},
|
||||
getCursorPosition: () => {
|
||||
const pos = editor?.state.selection.$anchor.pos
|
||||
return pos ? editor?.view.coordsAtPos(pos) : undefined
|
||||
|
||||
@@ -9,14 +9,17 @@ import {useAutoplayDisabled} from '#/state/preferences'
|
||||
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||
|
||||
export function VideoPreview({
|
||||
asset,
|
||||
video,
|
||||
clear,
|
||||
isActivePost,
|
||||
}: {
|
||||
asset: ImagePickerAsset
|
||||
video: CompressedVideo
|
||||
isActivePost: boolean
|
||||
setDimensions: (width: number, height: number) => void
|
||||
clear: () => void
|
||||
}) {
|
||||
@@ -42,13 +45,18 @@ export function VideoPreview({
|
||||
t.atoms.border_contrast_low,
|
||||
{backgroundColor: 'black'},
|
||||
]}>
|
||||
<BlueskyVideoView
|
||||
url={video.uri}
|
||||
autoplay={!autoplayDisabled}
|
||||
beginMuted={true}
|
||||
forceTakeover={true}
|
||||
ref={playerRef}
|
||||
/>
|
||||
<View style={[a.absolute, a.inset_0]}>
|
||||
<VideoTranscodeBackdrop uri={asset.uri} />
|
||||
</View>
|
||||
{isActivePost && (
|
||||
<BlueskyVideoView
|
||||
url={video.uri}
|
||||
autoplay={!autoplayDisabled}
|
||||
beginMuted={true}
|
||||
forceTakeover={true}
|
||||
ref={playerRef}
|
||||
/>
|
||||
)}
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
{autoplayDisabled && (
|
||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {PropsWithChildren, useMemo, useRef} from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
GestureResponderEvent,
|
||||
Insets,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
@@ -64,6 +65,7 @@ interface DropdownButtonProps {
|
||||
openUpwards?: boolean
|
||||
rightOffset?: number
|
||||
bottomOffset?: number
|
||||
hitSlop?: Insets
|
||||
accessibilityLabel?: string
|
||||
accessibilityHint?: string
|
||||
}
|
||||
@@ -80,6 +82,7 @@ export function DropdownButton({
|
||||
openUpwards = false,
|
||||
rightOffset = 0,
|
||||
bottomOffset = 0,
|
||||
hitSlop = HITSLOP_10,
|
||||
accessibilityLabel,
|
||||
}: PropsWithChildren<DropdownButtonProps>) {
|
||||
const {_} = useLingui()
|
||||
@@ -152,7 +155,7 @@ export function DropdownButton({
|
||||
testID={testID}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
hitSlop={HITSLOP_10}
|
||||
hitSlop={hitSlop}
|
||||
ref={ref1}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
|
||||
@@ -4114,10 +4114,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
|
||||
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
|
||||
|
||||
"@haileyok/bluesky-video@0.2.3":
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.2.3.tgz#096fc65d49b4811f79ecb83c39b44409902f4f3e"
|
||||
integrity sha512-1j1t/o2zrrh09LaZGzfoXQu+LuigJ1+HxQ30jq0naD2FEfQF1zLz6zOtDyywDSR8SUl9O0KsZxBi+wvQW1NgbA==
|
||||
"@haileyok/bluesky-video@0.2.4":
|
||||
version "0.2.4"
|
||||
resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.2.4.tgz#1591cbf744640e0cdac2a4bcaec22df916ce34b5"
|
||||
integrity sha512-Vm2rdZYPUwD8Ncxqyy+YlBVhtJFkLhutGyoZayZJ5ATV/S8msbN4RK8MgXLvzlJfW497cLbk1w6M8kPU1xoDEQ==
|
||||
|
||||
"@hapi/accept@^6.0.3":
|
||||
version "6.0.3"
|
||||
|
||||
Reference in New Issue
Block a user