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