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