From cd3b502b343e5e79d9a6df77d08935829b655f55 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 May 2024 19:10:00 +0300 Subject: [PATCH] =?UTF-8?q?[=F0=9F=90=B4]=20Option=20to=20share=20via=20ch?= =?UTF-8?q?at=20in=20post=20dropdown=20(#4231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- package.json | 2 +- src/components/dms/MessageItem.tsx | 2 +- src/components/dms/MessageItemEmbed.tsx | 99 +--- src/components/dms/dialogs/NewChatDialog.tsx | 67 +++ .../SearchablePeopleList.tsx} | 468 ++++++++---------- .../dms/dialogs/ShareViaChatDialog.tsx | 52 ++ .../{NewChatDialog => dialogs}/TextInput.tsx | 0 .../TextInput.web.tsx | 0 src/lib/routes/types.ts | 2 +- src/lib/statsig/events.ts | 8 +- .../Messages/Conversation/MessageInput.tsx | 23 +- .../Conversation/MessageInput.web.tsx | 14 +- .../Conversation/MessageInputEmbed.tsx | 231 +++++++++ .../Messages/Conversation/MessagesList.tsx | 87 ++-- src/screens/Messages/List/index.tsx | 2 +- src/state/messages/convo/agent.ts | 1 + src/state/queries/post.ts | 13 +- src/view/com/notifications/FeedItem.tsx | 2 +- src/view/com/util/forms/PostDropdownBtn.tsx | 36 +- src/view/com/util/images/ImageHorzList.tsx | 11 +- yarn.lock | 12 +- 21 files changed, 719 insertions(+), 413 deletions(-) create mode 100644 src/components/dms/dialogs/NewChatDialog.tsx rename src/components/dms/{NewChatDialog/index.tsx => dialogs/SearchablePeopleList.tsx} (86%) create mode 100644 src/components/dms/dialogs/ShareViaChatDialog.tsx rename src/components/dms/{NewChatDialog => dialogs}/TextInput.tsx (100%) rename src/components/dms/{NewChatDialog => dialogs}/TextInput.web.tsx (100%) create mode 100644 src/screens/Messages/Conversation/MessageInputEmbed.tsx diff --git a/package.json b/package.json index 642bf935..c107ea56 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index b498ddf1..772fcb1b 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -82,7 +82,7 @@ let MessageItem = ({ return ( - {AppBskyEmbedRecord.isMain(message.embed) && ( + {AppBskyEmbedRecord.isView(message.embed) && ( )} {rt.text.length > 0 && ( diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index d64563b9..5d3656ba 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -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 ( - - - - - - {rt.text && ( - - - - )} - {post.embed && ( - - )} - - - + + + ) } MessageItemEmbed = React.memo(MessageItemEmbed) diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx new file mode 100644 index 00000000..2b90fb02 --- /dev/null +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -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 ( + <> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New chat`)} + accessibilityHint="" + /> + + + + + + ) +} diff --git a/src/components/dms/NewChatDialog/index.tsx b/src/components/dms/dialogs/SearchablePeopleList.tsx similarity index 86% rename from src/components/dms/NewChatDialog/index.tsx rename to src/components/dms/dialogs/SearchablePeopleList.tsx index a6c30304..2c212e56 100644 --- a/src/components/dms/NewChatDialog/index.tsx +++ b/src/components/dms/dialogs/SearchablePeopleList.tsx @@ -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(null) + const {currentAccount} = useSession() + const inputRef = useRef(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 ( + + ) + } + case 'placeholder': { + return + } + case 'empty': { + return + } + default: + return null + } }, - [control, createChat], + [moderationOpts, onSelectChat], ) - return ( - <> - } - accessibilityRole="button" - accessibilityLabel={_(msg`New chat`)} - accessibilityHint="" - /> + useLayoutEffect(() => { + if (isWeb) { + setImmediate(() => { + inputRef?.current?.focus() + }) + } + }, []) - - - - + const listHeader = useMemo(() => { + return ( + + + + + {title} + + + + + { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onEscape={control.close} + /> + + + ) + }, [ + t.atoms.border_contrast_low, + t.atoms.bg, + t.atoms.text_contrast_high, + t.palette.contrast_500, + _, + title, + searchText, + control, + ]) + + return ( + 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({ ) } - -function SearchablePeopleList({ - onCreateChat, -}: { - onCreateChat: (did: string) => void -}) { - const t = useTheme() - const {_} = useLingui() - const moderationOpts = useModerationOpts() - const control = Dialog.useDialogContext() - const listRef = useRef(null) - const {currentAccount} = useSession() - const inputRef = useRef(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 ( - - ) - } - case 'placeholder': { - return - } - case 'empty': { - return - } - default: - return null - } - }, - [moderationOpts, onCreateChat], - ) - - useLayoutEffect(() => { - if (isWeb) { - setImmediate(() => { - inputRef?.current?.focus() - }) - } - }, []) - - const listHeader = useMemo(() => { - return ( - - - - - Start a new chat - - - - - { - setSearchText(text) - listRef.current?.scrollToOffset({offset: 0, animated: false}) - }} - onEscape={control.close} - /> - - - ) - }, [t, _, control, searchText]) - - return ( - 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" - /> - ) -} diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx new file mode 100644 index 00000000..ac475f7c --- /dev/null +++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx @@ -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 ( + + + + ) +} diff --git a/src/components/dms/NewChatDialog/TextInput.tsx b/src/components/dms/dialogs/TextInput.tsx similarity index 100% rename from src/components/dms/NewChatDialog/TextInput.tsx rename to src/components/dms/dialogs/TextInput.tsx diff --git a/src/components/dms/NewChatDialog/TextInput.web.tsx b/src/components/dms/dialogs/TextInput.web.tsx similarity index 100% rename from src/components/dms/NewChatDialog/TextInput.web.tsx rename to src/components/dms/dialogs/TextInput.web.tsx diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 5011aafd..7504cd83 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -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 } diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 00444c18..48651b3d 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -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': {} diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx index 14918868..c8229f95 100644 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -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() 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 ( + {children} 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) => { @@ -87,9 +95,11 @@ export function MessageInput({ ) useSaveMessageDraft(message) + useExtractEmbedFromFacets(message, setEmbed) return ( + {children} >() + const navigation = useNavigation() + 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 = ( + + + + ) + break + case 'error': + content = ( + + Could not fetch post + + ) + 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 = ( + + + + + {rt.text && ( + + + + )} + {images && images?.length > 0 && ( + + )} + + + ) + break + } + + return ( + + {content} + + + ) +} diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index d6aa06a1..e6f657b4 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -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() @@ -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 && } - + + + )} diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index 7c67c59d..0b1fe2a9 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -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' diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 9850124c..de2605b5 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -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__', diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index f27628d6..794f48eb 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -18,7 +18,16 @@ export function usePostQuery(uri: string | undefined) { return useQuery({ 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]) { diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index a5cc60fd..4b50946a 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -451,7 +451,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { return ( <> {text?.length > 0 && {text}} - {images && images?.length > 0 && ( + {images && images.length > 0 && ( )} diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index cd82ec98..945cf5e5 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -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() 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 && ( + + + Send via direct message + + + + )} + )} + + ) } diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx index e37f8af1..12eef14f 100644 --- a/src/view/com/util/images/ImageHorzList.tsx +++ b/src/view/com/util/images/ImageHorzList.tsx @@ -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, }, }) diff --git a/yarn.lock b/yarn.lock index 3e1246d9..ae18bfbe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==