diff --git a/package.json b/package.json index 642bf935fd..c107ea56e4 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 b498ddf1c4..772fcb1b11 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 d64563b912..5d3656bac1 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 0000000000..2b90fb02b2 --- /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 a6c3030439..2c212e56f9 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,247 +52,12 @@ type Item = key: string } -export function NewChat({ - control, - onNewChat, +export function SearchablePeopleList({ + title, + onSelectChat, }: { - 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="" - /> - - - - - - ) -} - -function ProfileCard({ - enabled, - profile, - moderationOpts, - onPress, -}: { - enabled: boolean - profile: AppBskyActorDefs.ProfileView - moderationOpts: ModerationOpts - onPress: (did: string) => void -}) { - const t = useTheme() - const {_} = useLingui() - const moderation = moderateProfile(profile, moderationOpts) - const handle = sanitizeHandle(profile.handle, '@') - const displayName = sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - ) - - const handleOnPress = useCallback(() => { - onPress(profile.did) - }, [onPress, profile.did]) - - return ( - - ) -} - -function ProfileCardSkeleton() { - const t = useTheme() - - return ( - - - - - - - - - ) -} - -function Empty({message}: {message: string}) { - const t = useTheme() - return ( - - - {message} - - - (╯°□°)╯︵ ┻━┻ - - ) -} - -function SearchInput({ - value, - onChangeText, - onEscape, - inputRef, -}: { - value: string - onChangeText: (text: string) => void - onEscape: () => void - inputRef: React.RefObject -}) { - const t = useTheme() - const {_} = useLingui() - const { - state: hovered, - onIn: onMouseEnter, - onOut: onMouseLeave, - } = useInteractionState() - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() - const interacted = hovered || focused - - return ( - - - - { - if (nativeEvent.key === 'Escape') { - onEscape() - } - }} - autoCorrect={false} - autoComplete="off" - autoCapitalize="none" - autoFocus - accessibilityLabel={_(msg`Search profiles`)} - accessibilityHint={_(msg`Search profiles`)} - /> - - ) -} - -function SearchablePeopleList({ - onCreateChat, -}: { - onCreateChat: (did: string) => void + title: string + onSelectChat: (did: string) => void }) { const t = useTheme() const {_} = useLingui() @@ -388,7 +148,7 @@ function SearchablePeopleList({ enabled={item.enabled} profile={item.profile} moderationOpts={moderationOpts!} - onPress={onCreateChat} + onPress={onSelectChat} /> ) } @@ -402,7 +162,7 @@ function SearchablePeopleList({ return null } }, - [moderationOpts, onCreateChat], + [moderationOpts, onSelectChat], ) useLayoutEffect(() => { @@ -464,7 +224,7 @@ function SearchablePeopleList({ a.leading_tight, t.atoms.text_contrast_high, ]}> - Start a new chat + {title} @@ -481,7 +241,16 @@ function SearchablePeopleList({ ) - }, [t, _, control, searchText]) + }, [ + t.atoms.border_contrast_low, + t.atoms.bg, + t.atoms.text_contrast_high, + t.palette.contrast_500, + _, + title, + searchText, + control, + ]) return ( ) } + +function ProfileCard({ + enabled, + profile, + moderationOpts, + onPress, +}: { + enabled: boolean + profile: AppBskyActorDefs.ProfileView + moderationOpts: ModerationOpts + onPress: (did: string) => void +}) { + const t = useTheme() + const {_} = useLingui() + const moderation = moderateProfile(profile, moderationOpts) + const handle = sanitizeHandle(profile.handle, '@') + const displayName = sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + + const handleOnPress = useCallback(() => { + onPress(profile.did) + }, [onPress, profile.did]) + + return ( + + ) +} + +function ProfileCardSkeleton() { + const t = useTheme() + + return ( + + + + + + + + + ) +} + +function Empty({message}: {message: string}) { + const t = useTheme() + return ( + + + {message} + + + (╯°□°)╯︵ ┻━┻ + + ) +} + +function SearchInput({ + value, + onChangeText, + onEscape, + inputRef, +}: { + value: string + onChangeText: (text: string) => void + onEscape: () => void + inputRef: React.RefObject +}) { + const t = useTheme() + const {_} = useLingui() + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const interacted = hovered || focused + + return ( + + + + { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + autoFocus + accessibilityLabel={_(msg`Search profiles`)} + accessibilityHint={_(msg`Search profiles`)} + /> + + ) +} diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx new file mode 100644 index 0000000000..ac475f7c99 --- /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 5011aafd79..7504cd83a0 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 00444c18c4..48651b3d96 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 1491886846..c8229f95dc 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 d6aa06a1ce..e6f657b497 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, - ) - - // 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}, + // 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) + + // 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 7c67c59d3f..0b1fe2a958 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 9850124c90..de2605b5ad 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 f27628d696..794f48eb1b 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 a5cc60fd81..4b50946a41 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 cd82ec98f0..945cf5e596 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 e37f8af1b7..12eef14f73 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 3e1246d92c..ae18bfbec6 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==