[🐴] 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>
zio/stable
Samuel Newman 2024-05-31 19:10:00 +03:00 committed by GitHub
parent 22e1eb18c8
commit cd3b502b34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 719 additions and 413 deletions

View File

@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "^0.12.13",
"@atproto/api": "^0.12.14",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",

View File

@ -82,7 +82,7 @@ let MessageItem = ({
return (
<View style={[isFromSelf ? a.mr_md : a.ml_md]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
{AppBskyEmbedRecord.isMain(message.embed) && (
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed embed={message.embed} />
)}
{rt.text.length > 0 && (

View File

@ -1,108 +1,21 @@
import React, {useMemo} from 'react'
import React from 'react'
import {View} from 'react-native'
import {
AppBskyEmbedRecord,
AppBskyFeedPost,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {AppBskyEmbedRecord} from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {makeProfileLink} from '#/lib/routes/links'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePostQuery} from '#/state/queries/post'
import {PostEmbeds} from '#/view/com/util/post-embeds'
import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf'
import {Link} from '#/components/Link'
import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText'
let MessageItemEmbed = ({
embed,
}: {
embed: AppBskyEmbedRecord.Main
embed: AppBskyEmbedRecord.View
}): React.ReactNode => {
const t = useTheme()
const {data: post} = usePostQuery(embed.record.uri)
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 (!post || !moderation || !rt || !record) {
return null
}
const itemUrip = new AtUri(post.uri)
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
return (
<Link to={itemHref}>
<View
style={[
a.w_full,
t.atoms.bg,
t.atoms.border_contrast_low,
a.rounded_md,
a.border,
a.p_md,
a.my_xs,
]}>
<PostMeta
showAvatar
author={post.author}
moderation={moderation}
authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt}
postHref={itemHref}
/>
<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}
/>
</View>
)}
{post.embed && (
<PostEmbeds
embed={post.embed}
moderation={moderation}
style={a.mt_xs}
quoteTextStyle={[a.text_sm, t.atoms.text_contrast_high]}
/>
)}
</ContentHider>
</View>
</Link>
<View style={[a.my_xs, t.atoms.bg, a.rounded_md, {flexBasis: 0}]}>
<PostEmbeds embed={embed} />
</View>
)
}
MessageItemEmbed = React.memo(MessageItemEmbed)

View File

@ -0,0 +1,67 @@
import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {SearchablePeopleList} from './SearchablePeopleList'
export function NewChat({
control,
onNewChat,
}: {
control: Dialog.DialogControlProps
onNewChat: (chatId: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onNewChat(data.convo.id)
if (!data.convo.lastMessage) {
logEvent('chat:create', {logContext: 'NewChatDialog'})
}
logEvent('chat:open', {logContext: 'NewChatDialog'})
},
onError: error => {
Toast.show(error.message)
},
})
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
},
[control, createChat],
)
return (
<>
<FAB
testID="newChatFAB"
onPress={control.open}
icon={<Plus size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New chat`)}
accessibilityHint=""
/>
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SearchablePeopleList
title={_(msg`Start a new chat`)}
onSelectChat={onCreateChat}
/>
</Dialog.Outer>
</>
)
}

View File

@ -16,23 +16,18 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useSession} from '#/state/session'
import {logEvent} from 'lib/statsig/statsig'
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
import {FAB} from '#/view/com/util/fab/FAB'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {TextInput} from '#/components/dms/NewChatDialog/TextInput'
import {TextInput} from '#/components/dms/dialogs/TextInput'
import {canBeMessaged} from '#/components/dms/util'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
@ -57,55 +52,228 @@ type Item =
key: string
}
export function NewChat({
control,
onNewChat,
export function SearchablePeopleList({
title,
onSelectChat,
}: {
control: Dialog.DialogControlProps
onNewChat: (chatId: string) => void
title: string
onSelectChat: (did: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const listRef = useRef<BottomSheetFlatListMethods>(null)
const {currentAccount} = useSession()
const inputRef = useRef<TextInputType>(null)
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onNewChat(data.convo.id)
const [searchText, setSearchText] = useState('')
if (!data.convo.lastMessage) {
logEvent('chat:create', {logContext: 'NewChatDialog'})
const {
data: results,
isError,
isFetching,
} = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const items = useMemo(() => {
let _items: Item[] = []
if (isError) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else if (searchText.length) {
if (results?.length) {
for (const profile of results) {
if (profile.did === currentAccount?.did) continue
_items.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
_items = _items.sort(a => {
// @ts-ignore
return a.enabled ? -1 : 1
})
}
logEvent('chat:open', {logContext: 'NewChatDialog'})
},
onError: error => {
Toast.show(error.message)
},
})
} else {
if (follows) {
for (const page of follows.pages) {
for (const profile of page.follows) {
_items.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
}
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
_items = _items.sort(a => {
// @ts-ignore
return a.enabled ? -1 : 1
})
} else {
Array(10)
.fill(0)
.forEach((_, i) => {
_items.push({
type: 'placeholder',
key: i + '',
})
})
}
}
return _items
}, [_, searchText, results, isError, currentAccount?.did, follows])
if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
const renderItems = useCallback(
({item}: {item: Item}) => {
switch (item.type) {
case 'profile': {
return (
<ProfileCard
key={item.key}
enabled={item.enabled}
profile={item.profile}
moderationOpts={moderationOpts!}
onPress={onSelectChat}
/>
)
}
case 'placeholder': {
return <ProfileCardSkeleton key={item.key} />
}
case 'empty': {
return <Empty key={item.key} message={item.message} />
}
default:
return null
}
},
[control, createChat],
[moderationOpts, onSelectChat],
)
return (
<>
<FAB
testID="newChatFAB"
onPress={control.open}
icon={<Plus size="lg" fill={t.palette.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New chat`)}
accessibilityHint=""
/>
useLayoutEffect(() => {
if (isWeb) {
setImmediate(() => {
inputRef?.current?.focus()
})
}
}, [])
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SearchablePeopleList onCreateChat={onCreateChat} />
</Dialog.Outer>
</>
const listHeader = useMemo(() => {
return (
<View
style={[
a.relative,
a.pt_md,
a.pb_xs,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
t.atoms.bg,
native([a.pt_lg]),
]}>
<View
style={[
a.relative,
native(a.align_center),
a.justify_center,
{height: 32},
]}>
<Button
label={_(msg`Close`)}
size="small"
shape="round"
variant="ghost"
color="secondary"
style={[
a.absolute,
a.z_20,
native({
left: -7,
}),
web({
right: -4,
}),
]}
onPress={() => control.close()}>
{isWeb ? (
<X size="md" fill={t.palette.contrast_500} />
) : (
<ChevronLeft size="md" fill={t.palette.contrast_500} />
)}
</Button>
<Text
style={[
a.z_10,
a.text_lg,
a.font_bold,
a.leading_tight,
t.atoms.text_contrast_high,
]}>
{title}
</Text>
</View>
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
<SearchInput
inputRef={inputRef}
value={searchText}
onChangeText={text => {
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onEscape={control.close}
/>
</View>
</View>
)
}, [
t.atoms.border_contrast_low,
t.atoms.bg,
t.atoms.text_contrast_high,
t.palette.contrast_500,
_,
title,
searchText,
control,
])
return (
<Dialog.InnerFlatList
ref={listRef}
data={items}
renderItem={renderItems}
ListHeaderComponent={listHeader}
stickyHeaderIndices={[0]}
keyExtractor={(item: Item) => item.key}
style={[
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
native({
height: '100%',
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag"
/>
)
}
@ -293,217 +461,3 @@ function SearchInput({
</View>
)
}
function SearchablePeopleList({
onCreateChat,
}: {
onCreateChat: (did: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const control = Dialog.useDialogContext()
const listRef = useRef<BottomSheetFlatListMethods>(null)
const {currentAccount} = useSession()
const inputRef = useRef<TextInputType>(null)
const [searchText, setSearchText] = useState('')
const {
data: results,
isError,
isFetching,
} = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const items = useMemo(() => {
let _items: Item[] = []
if (isError) {
_items.push({
type: 'empty',
key: 'empty',
message: _(msg`We're having network issues, try again`),
})
} else if (searchText.length) {
if (results?.length) {
for (const profile of results) {
if (profile.did === currentAccount?.did) continue
_items.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
_items = _items.sort(a => {
// @ts-ignore
return a.enabled ? -1 : 1
})
}
} else {
if (follows) {
for (const page of follows.pages) {
for (const profile of page.follows) {
_items.push({
type: 'profile',
key: profile.did,
enabled: canBeMessaged(profile),
profile,
})
}
}
_items = _items.sort(a => {
// @ts-ignore
return a.enabled ? -1 : 1
})
} else {
Array(10)
.fill(0)
.forEach((_, i) => {
_items.push({
type: 'placeholder',
key: i + '',
})
})
}
}
return _items
}, [_, searchText, results, isError, currentAccount?.did, follows])
if (searchText && !isFetching && !items.length && !isError) {
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
}
const renderItems = useCallback(
({item}: {item: Item}) => {
switch (item.type) {
case 'profile': {
return (
<ProfileCard
key={item.key}
enabled={item.enabled}
profile={item.profile}
moderationOpts={moderationOpts!}
onPress={onCreateChat}
/>
)
}
case 'placeholder': {
return <ProfileCardSkeleton key={item.key} />
}
case 'empty': {
return <Empty key={item.key} message={item.message} />
}
default:
return null
}
},
[moderationOpts, onCreateChat],
)
useLayoutEffect(() => {
if (isWeb) {
setImmediate(() => {
inputRef?.current?.focus()
})
}
}, [])
const listHeader = useMemo(() => {
return (
<View
style={[
a.relative,
a.pt_md,
a.pb_xs,
a.px_lg,
a.border_b,
t.atoms.border_contrast_low,
t.atoms.bg,
native([a.pt_lg]),
]}>
<View
style={[
a.relative,
native(a.align_center),
a.justify_center,
{height: 32},
]}>
<Button
label={_(msg`Close`)}
size="small"
shape="round"
variant="ghost"
color="secondary"
style={[
a.absolute,
a.z_20,
native({
left: -7,
}),
web({
right: -4,
}),
]}
onPress={() => control.close()}>
{isWeb ? (
<X size="md" fill={t.palette.contrast_500} />
) : (
<ChevronLeft size="md" fill={t.palette.contrast_500} />
)}
</Button>
<Text
style={[
a.z_10,
a.text_lg,
a.font_bold,
a.leading_tight,
t.atoms.text_contrast_high,
]}>
<Trans>Start a new chat</Trans>
</Text>
</View>
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
<SearchInput
inputRef={inputRef}
value={searchText}
onChangeText={text => {
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onEscape={control.close}
/>
</View>
</View>
)
}, [t, _, control, searchText])
return (
<Dialog.InnerFlatList
ref={listRef}
data={items}
renderItem={renderItems}
ListHeaderComponent={listHeader}
stickyHeaderIndices={[0]}
keyExtractor={(item: Item) => item.key}
style={[
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
native({
height: '100%',
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag"
/>
)
}

View File

@ -0,0 +1,52 @@
import React, {useCallback} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import * as Toast from '#/view/com/util/Toast'
import * as Dialog from '#/components/Dialog'
import {SearchablePeopleList} from './SearchablePeopleList'
export function SendViaChatDialog({
control,
onSelectChat,
}: {
control: Dialog.DialogControlProps
onSelectChat: (chatId: string) => void
}) {
const {_} = useLingui()
const {mutate: createChat} = useGetConvoForMembers({
onSuccess: data => {
onSelectChat(data.convo.id)
if (!data.convo.lastMessage) {
logEvent('chat:create', {logContext: 'SendViaChatDialog'})
}
logEvent('chat:open', {logContext: 'SendViaChatDialog'})
},
onError: error => {
Toast.show(error.message)
},
})
const onCreateChat = useCallback(
(did: string) => {
control.close(() => createChat([did]))
},
[control, createChat],
)
return (
<Dialog.Outer
control={control}
testID="sendViaChatChatDialog"
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
<SearchablePeopleList
title={_(msg`Send post to...`)}
onSelectChat={onCreateChat}
/>
</Dialog.Outer>
)
}

View File

@ -38,7 +38,7 @@ export type CommonNavigatorParams = {
AccessibilitySettings: undefined
Search: {q?: string}
Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string}
MessagesConversation: {conversation: string; embed?: string}
MessagesSettings: undefined
}

View File

@ -130,10 +130,14 @@ export type LogEvents = {
| 'AvatarButton'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog'
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
}
'chat:open': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'ChatsList'
logContext:
| 'ProfileHeader'
| 'NewChatDialog'
| 'ChatsList'
| 'SendViaChatDialog'
}
'test:all:always': {}

View File

@ -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,

View File

@ -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,

View 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>
)
}

View File

@ -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>

View File

@ -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'

View File

@ -1018,6 +1018,7 @@ export class Convo {
key: m.id,
message: {
...m.message,
embed: undefined,
$type: 'chat.bsky.convo.defs#messageView',
id: nanoid(),
rev: '__fake__',

View File

@ -18,7 +18,16 @@ export function usePostQuery(uri: string | undefined) {
return useQuery<AppBskyFeedDefs.PostView>({
queryKey: RQKEY(uri || ''),
async queryFn() {
const res = await agent.getPosts({uris: [uri!]})
const urip = new AtUri(uri!)
if (!urip.host.startsWith('did:')) {
const res = await agent.resolveHandle({
handle: urip.host,
})
urip.host = res.data.did
}
const res = await agent.getPosts({uris: [urip.toString()]})
if (res.success && res.data.posts[0]) {
return res.data.posts[0]
}
@ -47,7 +56,7 @@ export function useGetPost() {
}
const res = await agent.getPosts({
uris: [urip.toString()!],
uris: [urip.toString()],
})
if (res.success && res.data.posts[0]) {

View File

@ -451,7 +451,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
return (
<>
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
{images && images?.length > 0 && (
{images && images.length > 0 && (
<ImageHorzList images={images} style={styles.additionalPostImages} />
)}
</>

View File

@ -12,12 +12,12 @@ import {
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
@ -37,6 +37,7 @@ import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {EmbedDialog} from '#/components/dialogs/Embed'
import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
@ -49,6 +50,7 @@ import {
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
@ -102,13 +104,14 @@ let PostDropdownBtn = ({
const {hidePost} = useHiddenPostsApi()
const feedFeedback = useFeedFeedbackContext()
const openLink = useOpenLink()
const navigation = useNavigation()
const navigation = useNavigation<NavigationProp>()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const reportDialogControl = useReportDialogControl()
const deletePromptControl = useDialogControl()
const hidePromptControl = useDialogControl()
const loggedOutWarningPromptControl = useDialogControl()
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
const rootUri = record.reply?.root?.uri || postUri
const isThreadMuted = mutedThreads.includes(rootUri)
@ -229,6 +232,16 @@ let PostDropdownBtn = ({
Toast.show('Feedback sent!')
}, [feedFeedback, postUri, postFeedContext])
const onSelectChatToShareTo = React.useCallback(
(conversation: string) => {
navigation.navigate('MessagesConversation', {
conversation,
embed: postUri,
})
},
[navigation, postUri],
)
const canEmbed = isWeb && gtMobile && !hideInPWI
return (
@ -280,6 +293,18 @@ let PostDropdownBtn = ({
</>
)}
{hasSession && (
<Menu.Item
testID="postDropdownSendViaDMBtn"
label={_(msg`Send via direct message`)}
onPress={sendViaChatControl.open}>
<Menu.ItemText>
<Trans>Send via direct message</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Send} position="right" />
</Menu.Item>
)}
<Menu.Item
testID="postDropdownShareBtn"
label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
@ -449,6 +474,11 @@ let PostDropdownBtn = ({
timestamp={timestamp}
/>
)}
<SendViaChatDialog
control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo}
/>
</EventStopper>
)
}

View File

@ -27,11 +27,14 @@ export function ImageHorzList({images, style}: Props) {
}
const styles = StyleSheet.create({
flexRow: {flexDirection: 'row'},
flexRow: {
flexDirection: 'row',
gap: 5,
},
image: {
width: 100,
height: 100,
maxWidth: 100,
aspectRatio: 1,
flex: 1,
borderRadius: 4,
marginRight: 5,
},
})

View File

@ -34,10 +34,10 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
"@atproto/api@^0.12.13":
version "0.12.13"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.13.tgz#269d6c57ea894e23f20b28bd3cbfed944bd28528"
integrity sha512-pRSID6w8AUiZJoCxgctMPRTSGVFHq7wphAnxEbRLBP3OQ1g+BRZUcqFw+e+17Pd3wrc8VImjiD4HCWtCJvCx3w==
"@atproto/api@^0.12.14":
version "0.12.14"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.14.tgz#81252fd166ec8fe950056531e690d563437720fa"
integrity sha512-ZPh/afoRjFEQDQgMZW2FQiG5CDUifY7SxBqI0zVJUwed8Zi6fqYzGYM8fcDvD8yJfflRCqRxUE72g5fKiA1zAQ==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0"
@ -22564,12 +22564,12 @@ zod-validation-error@^3.0.3:
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af"
integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==
zod@^3.14.2, zod@^3.20.2, zod@^3.21.4:
zod@^3.14.2, zod@^3.20.2:
version "3.22.2"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b"
integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==
zod@^3.22.4:
zod@^3.21.4, zod@^3.22.4:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==