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:
dan
2024-11-01 03:45:55 +00:00
committed by GitHub
parent 01c9ac0e13
commit 7a08d61d88
11 changed files with 470 additions and 222 deletions
+1 -1
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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,
+4 -1
View File
@@ -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,
+80 -1
View File
@@ -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
+15 -7
View File
@@ -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]}>
+4 -1
View File
@@ -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={
+4 -4
View File
@@ -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"