[🐴] Option to share via chat in post dropdown (#4231)
* add send via chat button to post dropdown (cherry picked from commit d8458c0bc344f993266f7bc7e325d47e40619648) * let usePostQuery take uris with DIDs (cherry picked from commit 16b577ce749fd07e1d5f8461e8ca71c5b874a936) * add embed preview in composer (cherry picked from commit 795ceb98d55b6a3ab5b83187a582f9656d71db69) * rm log (cherry picked from commit 374d6b8869459f08d8442a3a47d67149e8d9ddd4) * remove params properly, or at least as close to (cherry picked from commit c20e0062c2ca4d9c2b28324eee5e713a1a3ab251) * show images in preview (cherry picked from commit 5bb617a3ce00f67bfc79784b2f81ef8dcb5bfc25) * Register embed immediately (cherry picked from commit ee120d5438a2c91c8980288665576d6a29b4c7e7) * Add hover to match embeds (cherry picked from commit 5297a5b06e499f46a9f6da510124610005db2448) * Update post dropdown copy (cherry picked from commit bc7e9f6a4303926a53c5c889f1f1b136faf20491) * Embed preview style tweaks (cherry picked from commit 9e3ccb0f25ac2f3ce6af538bb29112a3e96e01b1) * use hydrated posts from API and just use postembed component (cherry picked from commit cc0b84db87ca812d76cc69f46170ae84cfdde4ef) * fix type error (cherry picked from commit 9c49b940e1248e8a7c3b64190c5cb20750043619) * undo needless export (cherry picked from commit 1186701c997c50c0b29a809637cb9bc061b8c0a0) * fix overflow (cherry picked from commit 8868d5075062d0199c8ef6946fabde27e46ea378) --------- Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
parent
22e1eb18c8
commit
cd3b502b34
21 changed files with 719 additions and 413 deletions
|
@ -27,13 +27,20 @@ import * as Toast from '#/view/com/util/Toast'
|
|||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||
|
||||
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
hasEmbed,
|
||||
setEmbed,
|
||||
children,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
hasEmbed: boolean
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
@ -53,9 +60,10 @@ export function MessageInput({
|
|||
const inputRef = useAnimatedRef<TextInput>()
|
||||
|
||||
useSaveMessageDraft(message)
|
||||
useExtractEmbedFromFacets(message, setEmbed)
|
||||
|
||||
const onSubmit = React.useCallback(() => {
|
||||
if (message.trim() === '') {
|
||||
if (!hasEmbed && message.trim() === '') {
|
||||
return
|
||||
}
|
||||
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||
|
@ -66,13 +74,23 @@ export function MessageInput({
|
|||
onSendMessage(message)
|
||||
playHaptic()
|
||||
setMessage('')
|
||||
setEmbed(undefined)
|
||||
|
||||
// Pressing the send button causes the text input to lose focus, so we need to
|
||||
// re-focus it after sending
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 100)
|
||||
}, [message, clearDraft, onSendMessage, playHaptic, _, inputRef])
|
||||
}, [
|
||||
hasEmbed,
|
||||
message,
|
||||
clearDraft,
|
||||
onSendMessage,
|
||||
playHaptic,
|
||||
setEmbed,
|
||||
_,
|
||||
inputRef,
|
||||
])
|
||||
|
||||
useFocusedInputHandler(
|
||||
{
|
||||
|
@ -101,6 +119,7 @@ export function MessageInput({
|
|||
|
||||
return (
|
||||
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
|
||||
{children}
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
|
|
|
@ -16,11 +16,18 @@ import * as Toast from '#/view/com/util/Toast'
|
|||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
hasEmbed,
|
||||
setEmbed,
|
||||
children,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
hasEmbed: boolean
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const {_} = useLingui()
|
||||
|
@ -35,7 +42,7 @@ export function MessageInput({
|
|||
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
||||
|
||||
const onSubmit = React.useCallback(() => {
|
||||
if (message.trim() === '') {
|
||||
if (!hasEmbed && message.trim() === '') {
|
||||
return
|
||||
}
|
||||
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||
|
@ -45,7 +52,8 @@ export function MessageInput({
|
|||
clearDraft()
|
||||
onSendMessage(message)
|
||||
setMessage('')
|
||||
}, [message, onSendMessage, _, clearDraft])
|
||||
setEmbed(undefined)
|
||||
}, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
|
||||
|
||||
const onKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
|
@ -87,9 +95,11 @@ export function MessageInput({
|
|||
)
|
||||
|
||||
useSaveMessageDraft(message)
|
||||
useExtractEmbedFromFacets(message, setEmbed)
|
||||
|
||||
return (
|
||||
<View style={a.p_sm}>
|
||||
{children}
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
|
|
231
src/screens/Messages/Conversation/MessageInputEmbed.tsx
Normal file
231
src/screens/Messages/Conversation/MessageInputEmbed.tsx
Normal file
|
@ -0,0 +1,231 @@
|
|||
import React, {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import {LayoutAnimation, View} from 'react-native'
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedPost,
|
||||
AppBskyRichtextFacet,
|
||||
AtUri,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {RouteProp, useNavigation, useRoute} from '@react-navigation/native'
|
||||
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isBskyPostUrl,
|
||||
makeRecordUri,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {usePostQuery} from '#/state/queries/post'
|
||||
import {ImageHorzList} from '#/view/com/util/images/ImageHorzList'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function useMessageEmbed() {
|
||||
const route =
|
||||
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const embedFromParams = route.params.embed
|
||||
|
||||
const [embedUri, setEmbed] = useState(embedFromParams)
|
||||
|
||||
if (embedFromParams && embedUri !== embedFromParams) {
|
||||
setEmbed(embedFromParams)
|
||||
}
|
||||
|
||||
return {
|
||||
embedUri,
|
||||
setEmbed: useCallback(
|
||||
(embedUrl: string | undefined) => {
|
||||
if (!embedUrl) {
|
||||
navigation.setParams({embed: ''})
|
||||
setEmbed(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (embedFromParams) return
|
||||
|
||||
const url = convertBskyAppUrlIfNeeded(embedUrl)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
|
||||
setEmbed(uri)
|
||||
},
|
||||
[embedFromParams, navigation],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function useExtractEmbedFromFacets(
|
||||
message: string,
|
||||
setEmbed: (embedUrl: string | undefined) => void,
|
||||
) {
|
||||
const rt = new RichTextAPI({text: message})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
let uriFromFacet: string | undefined
|
||||
|
||||
for (const facet of rt.facets ?? []) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
|
||||
uriFromFacet = feature.uri
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (uriFromFacet) {
|
||||
setEmbed(uriFromFacet)
|
||||
}
|
||||
}, [uriFromFacet, setEmbed])
|
||||
}
|
||||
|
||||
export function MessageInputEmbed({
|
||||
embedUri,
|
||||
setEmbed,
|
||||
}: {
|
||||
embedUri: string | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const {data: post, status} = usePostQuery(embedUri)
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = useMemo(
|
||||
() =>
|
||||
moderationOpts && post ? moderatePost(post, moderationOpts) : undefined,
|
||||
[moderationOpts, post],
|
||||
)
|
||||
|
||||
const {rt, record} = useMemo(() => {
|
||||
if (
|
||||
post &&
|
||||
AppBskyFeedPost.isRecord(post.record) &&
|
||||
AppBskyFeedPost.validateRecord(post.record).success
|
||||
) {
|
||||
return {
|
||||
rt: new RichTextAPI({
|
||||
text: post.record.text,
|
||||
facets: post.record.facets,
|
||||
}),
|
||||
record: post.record,
|
||||
}
|
||||
}
|
||||
|
||||
return {rt: undefined, record: undefined}
|
||||
}, [post])
|
||||
|
||||
if (!embedUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
let content = null
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
content = (
|
||||
<View
|
||||
style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Loader />
|
||||
</View>
|
||||
)
|
||||
break
|
||||
case 'error':
|
||||
content = (
|
||||
<View
|
||||
style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Text style={a.text_center}>Could not fetch post</Text>
|
||||
</View>
|
||||
)
|
||||
break
|
||||
case 'success':
|
||||
const itemUrip = new AtUri(post.uri)
|
||||
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
|
||||
|
||||
if (!post || !moderation || !rt || !record) {
|
||||
return null
|
||||
}
|
||||
|
||||
const images = AppBskyEmbedImages.isView(post.embed)
|
||||
? post.embed.images
|
||||
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
||||
AppBskyEmbedImages.isView(post.embed.media)
|
||||
? post.embed.media.images
|
||||
: undefined
|
||||
|
||||
content = (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.p_sm,
|
||||
a.mb_sm,
|
||||
]}
|
||||
pointerEvents="none">
|
||||
<PostMeta
|
||||
showAvatar
|
||||
author={post.author}
|
||||
moderation={moderation}
|
||||
authorHasWarning={!!post.author.labels?.length}
|
||||
timestamp={post.indexedAt}
|
||||
postHref={itemHref}
|
||||
style={a.flex_0}
|
||||
/>
|
||||
<ContentHider modui={moderation.ui('contentView')}>
|
||||
<PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} />
|
||||
{rt.text && (
|
||||
<View style={a.mt_xs}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={rt}
|
||||
style={[a.text_sm, t.atoms.text_contrast_high]}
|
||||
authorHandle={post.author.handle}
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{images && images?.length > 0 && (
|
||||
<ImageHorzList images={images} style={a.mt_xs} />
|
||||
)}
|
||||
</ContentHider>
|
||||
</View>
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
{content}
|
||||
<Button
|
||||
label={_(msg`Remove embed`)}
|
||||
onPress={() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}}
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="round">
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
|
@ -15,9 +15,11 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
|
|||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||
|
||||
import {getPostAsQuote} from '#/lib/link-meta/bsky'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isBskyPostUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {isConvoActive, useConvoActive} from '#/state/messages/convo'
|
||||
|
@ -36,6 +38,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
|
|||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
||||
|
||||
function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
||||
return (
|
||||
|
@ -85,6 +88,7 @@ export function MessagesList({
|
|||
const convoState = useConvoActive()
|
||||
const agent = useAgent()
|
||||
const getPost = useGetPost()
|
||||
const {embedUri, setEmbed} = useMessageEmbed()
|
||||
|
||||
const flatListRef = useAnimatedRef<FlatList>()
|
||||
|
||||
|
@ -277,25 +281,10 @@ export function MessagesList({
|
|||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
let embed: AppBskyEmbedRecord.Main | undefined
|
||||
// find the first link facet that is a link to a post
|
||||
const postLinkFacet = rt.facets?.find(facet => {
|
||||
return facet.features.find(feature => {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
return isBskyPostUrl(feature.uri)
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
// if we found a post link, get the post and embed it
|
||||
if (postLinkFacet) {
|
||||
const postLink = postLinkFacet.features.find(
|
||||
AppBskyRichtextFacet.isLink,
|
||||
)
|
||||
if (!postLink) return
|
||||
|
||||
if (embedUri) {
|
||||
try {
|
||||
const post = await getPostAsQuote(getPost, postLink.uri)
|
||||
const post = await getPost({uri: embedUri})
|
||||
if (post) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.record',
|
||||
|
@ -305,24 +294,43 @@ export function MessagesList({
|
|||
},
|
||||
}
|
||||
|
||||
// remove the post link from the text
|
||||
rt.delete(
|
||||
postLinkFacet.index.byteStart,
|
||||
postLinkFacet.index.byteEnd,
|
||||
)
|
||||
// look for the embed uri in the facets, so we can remove it from the text
|
||||
const postLinkFacet = rt.facets?.find(facet => {
|
||||
return facet.features.find(feature => {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
if (isBskyPostUrl(feature.uri)) {
|
||||
const url = convertBskyAppUrlIfNeeded(feature.uri)
|
||||
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
|
||||
|
||||
// re-trim the text, now that we've removed the post link
|
||||
//
|
||||
// if the post link is at the start of the text, we don't want to leave a leading space
|
||||
// so trim on both sides
|
||||
if (postLinkFacet.index.byteStart === 0) {
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
} else {
|
||||
// otherwise just trim the end
|
||||
rt = new RichText(
|
||||
{text: rt.text.trimEnd()},
|
||||
{cleanNewlines: true},
|
||||
// this might have a handle instead of a DID
|
||||
// so just compare the rkey - not particularly dangerous
|
||||
return post.uri.endsWith(rkey)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
if (postLinkFacet) {
|
||||
// remove the post link from the text
|
||||
rt.delete(
|
||||
postLinkFacet.index.byteStart,
|
||||
postLinkFacet.index.byteEnd,
|
||||
)
|
||||
|
||||
// re-trim the text, now that we've removed the post link
|
||||
//
|
||||
// if the post link is at the start of the text, we don't want to leave a leading space
|
||||
// so trim on both sides
|
||||
if (postLinkFacet.index.byteStart === 0) {
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
} else {
|
||||
// otherwise just trim the end
|
||||
rt = new RichText(
|
||||
{text: rt.text.trimEnd()},
|
||||
{cleanNewlines: true},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -345,7 +353,7 @@ export function MessagesList({
|
|||
embed,
|
||||
})
|
||||
},
|
||||
[agent, convoState, getPost, hasScrolled, setHasScrolled],
|
||||
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
|
||||
)
|
||||
|
||||
// -- List layout changes (opening emoji keyboard, etc.)
|
||||
|
@ -420,7 +428,12 @@ export function MessagesList({
|
|||
{isConvoActive(convoState) &&
|
||||
!convoState.isFetchingHistory &&
|
||||
convoState.items.length === 0 && <ChatEmptyPill />}
|
||||
<MessageInput onSendMessage={onSendMessage} />
|
||||
<MessageInput
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}>
|
||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||
</MessageInput>
|
||||
</>
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
|
|
|
@ -21,8 +21,8 @@ import {CenteredView} from '#/view/com/util/Views'
|
|||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
|
||||
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
|
||||
import {MessagesNUX} from '#/components/dms/MessagesNUX'
|
||||
import {NewChat} from '#/components/dms/NewChatDialog'
|
||||
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue