diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-05-31 19:10:00 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-05-31 11:10:00 -0500 |
commit | cd3b502b343e5e79d9a6df77d08935829b655f55 (patch) | |
tree | 5f16d11441049dc8e2b0ed1ec029dfc6fc6832af /src | |
parent | 22e1eb18c81b6f41927bc86d4726223c2634e19e (diff) | |
download | voidsky-cd3b502b343e5e79d9a6df77d08935829b655f55.tar.zst |
[🐴] Option to share via chat in post dropdown (#4231)
* add send via chat button to post dropdown (cherry picked from commit d8458c0bc344f993266f7bc7e325d47e40619648) * let usePostQuery take uris with DIDs (cherry picked from commit 16b577ce749fd07e1d5f8461e8ca71c5b874a936) * add embed preview in composer (cherry picked from commit 795ceb98d55b6a3ab5b83187a582f9656d71db69) * rm log (cherry picked from commit 374d6b8869459f08d8442a3a47d67149e8d9ddd4) * remove params properly, or at least as close to (cherry picked from commit c20e0062c2ca4d9c2b28324eee5e713a1a3ab251) * show images in preview (cherry picked from commit 5bb617a3ce00f67bfc79784b2f81ef8dcb5bfc25) * Register embed immediately (cherry picked from commit ee120d5438a2c91c8980288665576d6a29b4c7e7) * Add hover to match embeds (cherry picked from commit 5297a5b06e499f46a9f6da510124610005db2448) * Update post dropdown copy (cherry picked from commit bc7e9f6a4303926a53c5c889f1f1b136faf20491) * Embed preview style tweaks (cherry picked from commit 9e3ccb0f25ac2f3ce6af538bb29112a3e96e01b1) * use hydrated posts from API and just use postembed component (cherry picked from commit cc0b84db87ca812d76cc69f46170ae84cfdde4ef) * fix type error (cherry picked from commit 9c49b940e1248e8a7c3b64190c5cb20750043619) * undo needless export (cherry picked from commit 1186701c997c50c0b29a809637cb9bc061b8c0a0) * fix overflow (cherry picked from commit 8868d5075062d0199c8ef6946fabde27e46ea378) --------- Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src')
19 files changed, 706 insertions, 400 deletions
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index b498ddf1c..772fcb1b1 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -82,7 +82,7 @@ let MessageItem = ({ return ( <View style={[isFromSelf ? a.mr_md : a.ml_md]}> <ActionsWrapper isFromSelf={isFromSelf} message={message}> - {AppBskyEmbedRecord.isMain(message.embed) && ( + {AppBskyEmbedRecord.isView(message.embed) && ( <MessageItemEmbed embed={message.embed} /> )} {rt.text.length > 0 && ( diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index d64563b91..5d3656bac 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 ( - <Link to={itemHref}> - <View - style={[ - a.w_full, - t.atoms.bg, - t.atoms.border_contrast_low, - a.rounded_md, - a.border, - a.p_md, - a.my_xs, - ]}> - <PostMeta - showAvatar - author={post.author} - moderation={moderation} - authorHasWarning={!!post.author.labels?.length} - timestamp={post.indexedAt} - postHref={itemHref} - /> - <ContentHider modui={moderation.ui('contentView')}> - <PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} /> - {rt.text && ( - <View style={a.mt_xs}> - <RichText - enableTags - testID="postText" - value={rt} - style={[a.text_sm, t.atoms.text_contrast_high]} - authorHandle={post.author.handle} - /> - </View> - )} - {post.embed && ( - <PostEmbeds - embed={post.embed} - moderation={moderation} - style={a.mt_xs} - quoteTextStyle={[a.text_sm, t.atoms.text_contrast_high]} - /> - )} - </ContentHider> - </View> - </Link> + <View style={[a.my_xs, t.atoms.bg, a.rounded_md, {flexBasis: 0}]}> + <PostEmbeds embed={embed} /> + </View> ) } MessageItemEmbed = React.memo(MessageItemEmbed) diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx new file mode 100644 index 000000000..2b90fb02b --- /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 ( + <> + <FAB + testID="newChatFAB" + onPress={control.open} + icon={<Plus size="lg" fill={t.palette.white} />} + accessibilityRole="button" + accessibilityLabel={_(msg`New chat`)} + accessibilityHint="" + /> + + <Dialog.Outer + control={control} + testID="newChatDialog" + nativeOptions={{sheet: {snapPoints: ['100%']}}}> + <SearchablePeopleList + title={_(msg`Start a new chat`)} + onSelectChat={onCreateChat} + /> + </Dialog.Outer> + </> + ) +} diff --git a/src/components/dms/NewChatDialog/index.tsx b/src/components/dms/dialogs/SearchablePeopleList.tsx index a6c303043..2c212e56f 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 ( - <> - <FAB - testID="newChatFAB" - onPress={control.open} - icon={<Plus size="lg" fill={t.palette.white} />} - accessibilityRole="button" - accessibilityLabel={_(msg`New chat`)} - accessibilityHint="" - /> - - <Dialog.Outer - control={control} - testID="newChatDialog" - nativeOptions={{sheet: {snapPoints: ['100%']}}}> - <SearchablePeopleList onCreateChat={onCreateChat} /> - </Dialog.Outer> - </> - ) -} - -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 ( - <Button - disabled={!enabled} - label={_(msg`Start chat with ${displayName}`)} - onPress={handleOnPress}> - {({hovered, pressed, focused}) => ( - <View - style={[ - a.flex_1, - a.py_md, - a.px_lg, - a.gap_md, - a.align_center, - a.flex_row, - !enabled - ? {opacity: 0.5} - : pressed || focused - ? t.atoms.bg_contrast_25 - : hovered - ? t.atoms.bg_contrast_50 - : t.atoms.bg, - ]}> - <UserAvatar - size={42} - avatar={profile.avatar} - moderation={moderation.ui('avatar')} - type={profile.associated?.labeler ? 'labeler' : 'user'} - /> - <View style={[a.flex_1, a.gap_2xs]}> - <Text - style={[t.atoms.text, a.font_bold, a.leading_snug]} - numberOfLines={1}> - {displayName} - </Text> - <Text style={t.atoms.text_contrast_high} numberOfLines={2}> - {!enabled ? <Trans>{handle} can't be messaged</Trans> : handle} - </Text> - </View> - </View> - )} - </Button> - ) -} - -function ProfileCardSkeleton() { - const t = useTheme() - - return ( - <View - style={[ - a.flex_1, - a.py_md, - a.px_lg, - a.gap_md, - a.align_center, - a.flex_row, - ]}> - <View - style={[ - a.rounded_full, - {width: 42, height: 42}, - t.atoms.bg_contrast_25, - ]} - /> - - <View style={[a.flex_1, a.gap_sm]}> - <View - style={[ - a.rounded_xs, - {width: 80, height: 14}, - t.atoms.bg_contrast_25, - ]} - /> - <View - style={[ - a.rounded_xs, - {width: 120, height: 10}, - t.atoms.bg_contrast_25, - ]} - /> - </View> - </View> - ) -} - -function Empty({message}: {message: string}) { - const t = useTheme() - return ( - <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> - <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> - {message} - </Text> - - <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text> - </View> - ) -} - -function SearchInput({ - value, - onChangeText, - onEscape, - inputRef, -}: { - value: string - onChangeText: (text: string) => void - onEscape: () => void - inputRef: React.RefObject<TextInputType> -}) { - 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 ( - <View - {...web({ - onMouseEnter, - onMouseLeave, - })} - style={[a.flex_row, a.align_center, a.gap_sm]}> - <Search - size="md" - fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} - /> - - <TextInput - // @ts-ignore bottom sheet input types issue — esb - ref={inputRef} - placeholder={_(msg`Search`)} - value={value} - onChangeText={onChangeText} - onFocus={onFocus} - onBlur={onBlur} - style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} - placeholderTextColor={t.palette.contrast_500} - keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} - returnKeyType="search" - clearButtonMode="while-editing" - maxLength={50} - onKeyPress={({nativeEvent}) => { - if (nativeEvent.key === 'Escape') { - onEscape() - } - }} - autoCorrect={false} - autoComplete="off" - autoCapitalize="none" - autoFocus - accessibilityLabel={_(msg`Search profiles`)} - accessibilityHint={_(msg`Search profiles`)} - /> - </View> - ) -} - -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, ]}> - <Trans>Start a new chat</Trans> + {title} </Text> </View> @@ -481,7 +241,16 @@ function SearchablePeopleList({ </View> </View> ) - }, [t, _, control, searchText]) + }, [ + t.atoms.border_contrast_low, + t.atoms.bg, + t.atoms.text_contrast_high, + t.palette.contrast_500, + _, + title, + searchText, + control, + ]) return ( <Dialog.InnerFlatList @@ -507,3 +276,188 @@ function SearchablePeopleList({ /> ) } + +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 ( + <Button + disabled={!enabled} + label={_(msg`Start chat with ${displayName}`)} + onPress={handleOnPress}> + {({hovered, pressed, focused}) => ( + <View + style={[ + a.flex_1, + a.py_md, + a.px_lg, + a.gap_md, + a.align_center, + a.flex_row, + !enabled + ? {opacity: 0.5} + : pressed || focused + ? t.atoms.bg_contrast_25 + : hovered + ? t.atoms.bg_contrast_50 + : t.atoms.bg, + ]}> + <UserAvatar + size={42} + avatar={profile.avatar} + moderation={moderation.ui('avatar')} + type={profile.associated?.labeler ? 'labeler' : 'user'} + /> + <View style={[a.flex_1, a.gap_2xs]}> + <Text + style={[t.atoms.text, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {displayName} + </Text> + <Text style={t.atoms.text_contrast_high} numberOfLines={2}> + {!enabled ? <Trans>{handle} can't be messaged</Trans> : handle} + </Text> + </View> + </View> + )} + </Button> + ) +} + +function ProfileCardSkeleton() { + const t = useTheme() + + return ( + <View + style={[ + a.flex_1, + a.py_md, + a.px_lg, + a.gap_md, + a.align_center, + a.flex_row, + ]}> + <View + style={[ + a.rounded_full, + {width: 42, height: 42}, + t.atoms.bg_contrast_25, + ]} + /> + + <View style={[a.flex_1, a.gap_sm]}> + <View + style={[ + a.rounded_xs, + {width: 80, height: 14}, + t.atoms.bg_contrast_25, + ]} + /> + <View + style={[ + a.rounded_xs, + {width: 120, height: 10}, + t.atoms.bg_contrast_25, + ]} + /> + </View> + </View> + ) +} + +function Empty({message}: {message: string}) { + const t = useTheme() + return ( + <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> + <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> + {message} + </Text> + + <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text> + </View> + ) +} + +function SearchInput({ + value, + onChangeText, + onEscape, + inputRef, +}: { + value: string + onChangeText: (text: string) => void + onEscape: () => void + inputRef: React.RefObject<TextInputType> +}) { + 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 ( + <View + {...web({ + onMouseEnter, + onMouseLeave, + })} + style={[a.flex_row, a.align_center, a.gap_sm]}> + <Search + size="md" + fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} + /> + + <TextInput + // @ts-ignore bottom sheet input types issue — esb + ref={inputRef} + placeholder={_(msg`Search`)} + value={value} + onChangeText={onChangeText} + onFocus={onFocus} + onBlur={onBlur} + style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} + placeholderTextColor={t.palette.contrast_500} + keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} + returnKeyType="search" + clearButtonMode="while-editing" + maxLength={50} + onKeyPress={({nativeEvent}) => { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + autoFocus + accessibilityLabel={_(msg`Search profiles`)} + accessibilityHint={_(msg`Search profiles`)} + /> + </View> + ) +} diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx new file mode 100644 index 000000000..ac475f7c9 --- /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 ( + <Dialog.Outer + control={control} + testID="sendViaChatChatDialog" + nativeOptions={{sheet: {snapPoints: ['100%']}}}> + <SearchablePeopleList + title={_(msg`Send post to...`)} + onSelectChat={onCreateChat} + /> + </Dialog.Outer> + ) +} diff --git a/src/components/dms/NewChatDialog/TextInput.tsx b/src/components/dms/dialogs/TextInput.tsx index b4e77e3e0..b4e77e3e0 100644 --- a/src/components/dms/NewChatDialog/TextInput.tsx +++ b/src/components/dms/dialogs/TextInput.tsx diff --git a/src/components/dms/NewChatDialog/TextInput.web.tsx b/src/components/dms/dialogs/TextInput.web.tsx index 5371a534f..5371a534f 100644 --- a/src/components/dms/NewChatDialog/TextInput.web.tsx +++ b/src/components/dms/dialogs/TextInput.web.tsx diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 5011aafd7..7504cd83a 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 00444c18c..48651b3d9 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 149188684..c8229f95d 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<TextInput>() useSaveMessageDraft(message) + useExtractEmbedFromFacets(message, setEmbed) const onSubmit = React.useCallback(() => { - if (message.trim() === '') { + if (!hasEmbed && message.trim() === '') { return } if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { @@ -66,13 +74,23 @@ export function MessageInput({ onSendMessage(message) playHaptic() setMessage('') + setEmbed(undefined) // Pressing the send button causes the text input to lose focus, so we need to // re-focus it after sending setTimeout(() => { inputRef.current?.focus() }, 100) - }, [message, clearDraft, onSendMessage, playHaptic, _, inputRef]) + }, [ + hasEmbed, + message, + clearDraft, + onSendMessage, + playHaptic, + setEmbed, + _, + inputRef, + ]) useFocusedInputHandler( { @@ -101,6 +119,7 @@ export function MessageInput({ return ( <View style={[a.px_md, a.pb_sm, a.pt_xs]}> + {children} <View style={[ a.w_full, diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx index a61355e55..b9181774e 100644 --- a/src/screens/Messages/Conversation/MessageInput.web.tsx +++ b/src/screens/Messages/Conversation/MessageInput.web.tsx @@ -16,11 +16,18 @@ import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {useSharedInputStyles} from '#/components/forms/TextField' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' +import {useExtractEmbedFromFacets} from './MessageInputEmbed' export function MessageInput({ onSendMessage, + hasEmbed, + setEmbed, + children, }: { onSendMessage: (message: string) => void + hasEmbed: boolean + setEmbed: (embedUrl: string | undefined) => void + children?: React.ReactNode }) { const {isTabletOrDesktop} = useWebMediaQueries() const {_} = useLingui() @@ -35,7 +42,7 @@ export function MessageInput({ const [textAreaHeight, setTextAreaHeight] = React.useState(38) const onSubmit = React.useCallback(() => { - if (message.trim() === '') { + if (!hasEmbed && message.trim() === '') { return } if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { @@ -45,7 +52,8 @@ export function MessageInput({ clearDraft() onSendMessage(message) setMessage('') - }, [message, onSendMessage, _, clearDraft]) + setEmbed(undefined) + }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed]) const onKeyDown = React.useCallback( (e: React.KeyboardEvent<HTMLTextAreaElement>) => { @@ -87,9 +95,11 @@ export function MessageInput({ ) useSaveMessageDraft(message) + useExtractEmbedFromFacets(message, setEmbed) return ( <View style={a.p_sm}> + {children} <View style={[ a.flex_row, diff --git a/src/screens/Messages/Conversation/MessageInputEmbed.tsx b/src/screens/Messages/Conversation/MessageInputEmbed.tsx new file mode 100644 index 000000000..4fdd31bcf --- /dev/null +++ b/src/screens/Messages/Conversation/MessageInputEmbed.tsx @@ -0,0 +1,231 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react' +import {LayoutAnimation, View} from 'react-native' +import { + AppBskyEmbedImages, + AppBskyEmbedRecordWithMedia, + AppBskyFeedPost, + AppBskyRichtextFacet, + AtUri, + RichText as RichTextAPI, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {RouteProp, useNavigation, useRoute} from '@react-navigation/native' + +import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {makeProfileLink} from '#/lib/routes/links' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import { + convertBskyAppUrlIfNeeded, + isBskyPostUrl, + makeRecordUri, +} from '#/lib/strings/url-helpers' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {usePostQuery} from '#/state/queries/post' +import {ImageHorzList} from '#/view/com/util/images/ImageHorzList' +import {PostMeta} from '#/view/com/util/PostMeta' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Loader} from '#/components/Loader' +import {ContentHider} from '#/components/moderation/ContentHider' +import {PostAlerts} from '#/components/moderation/PostAlerts' +import {RichText} from '#/components/RichText' +import {Text} from '#/components/Typography' + +export function useMessageEmbed() { + const route = + useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>() + const navigation = useNavigation<NavigationProp>() + const embedFromParams = route.params.embed + + const [embedUri, setEmbed] = useState(embedFromParams) + + if (embedFromParams && embedUri !== embedFromParams) { + setEmbed(embedFromParams) + } + + return { + embedUri, + setEmbed: useCallback( + (embedUrl: string | undefined) => { + if (!embedUrl) { + navigation.setParams({embed: ''}) + setEmbed(undefined) + return + } + + if (embedFromParams) return + + const url = convertBskyAppUrlIfNeeded(embedUrl) + const [_0, user, _1, rkey] = url.split('/').filter(Boolean) + const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) + + setEmbed(uri) + }, + [embedFromParams, navigation], + ), + } +} + +export function useExtractEmbedFromFacets( + message: string, + setEmbed: (embedUrl: string | undefined) => void, +) { + const rt = new RichTextAPI({text: message}) + rt.detectFacetsWithoutResolution() + + let uriFromFacet: string | undefined + + for (const facet of rt.facets ?? []) { + for (const feature of facet.features) { + if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) { + uriFromFacet = feature.uri + break + } + } + } + + useEffect(() => { + if (uriFromFacet) { + setEmbed(uriFromFacet) + } + }, [uriFromFacet, setEmbed]) +} + +export function MessageInputEmbed({ + embedUri, + setEmbed, +}: { + embedUri: string | undefined + setEmbed: (embedUrl: string | undefined) => void +}) { + const t = useTheme() + const {_} = useLingui() + + const {data: post, status} = usePostQuery(embedUri) + + const moderationOpts = useModerationOpts() + const moderation = useMemo( + () => + moderationOpts && post ? moderatePost(post, moderationOpts) : undefined, + [moderationOpts, post], + ) + + const {rt, record} = useMemo(() => { + if ( + post && + AppBskyFeedPost.isRecord(post.record) && + AppBskyFeedPost.validateRecord(post.record).success + ) { + return { + rt: new RichTextAPI({ + text: post.record.text, + facets: post.record.facets, + }), + record: post.record, + } + } + + return {rt: undefined, record: undefined} + }, [post]) + + if (!embedUri) { + return null + } + + let content = null + switch (status) { + case 'pending': + content = ( + <View + style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}> + <Loader /> + </View> + ) + break + case 'error': + content = ( + <View + style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}> + <Text style={a.text_center}>Could not fetch post</Text> + </View> + ) + break + case 'success': + const itemUrip = new AtUri(post.uri) + const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey) + + if (!post || !moderation || !rt || !record) { + return null + } + + const images = AppBskyEmbedImages.isView(post.embed) + ? post.embed.images + : AppBskyEmbedRecordWithMedia.isView(post.embed) && + AppBskyEmbedImages.isView(post.embed.media) + ? post.embed.media.images + : undefined + + content = ( + <View + style={[ + a.flex_1, + t.atoms.bg, + t.atoms.border_contrast_low, + a.rounded_md, + a.border, + a.p_sm, + a.mb_sm, + ]} + pointerEvents="none"> + <PostMeta + showAvatar + author={post.author} + moderation={moderation} + authorHasWarning={!!post.author.labels?.length} + timestamp={post.indexedAt} + postHref={itemHref} + style={a.flex_0} + /> + <ContentHider modui={moderation.ui('contentView')}> + <PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} /> + {rt.text && ( + <View style={a.mt_xs}> + <RichText + enableTags + testID="postText" + value={rt} + style={[a.text_sm, t.atoms.text_contrast_high]} + authorHandle={post.author.handle} + numberOfLines={3} + /> + </View> + )} + {images && images?.length > 0 && ( + <ImageHorzList images={images} style={a.mt_xs} /> + )} + </ContentHider> + </View> + ) + break + } + + return ( + <View style={[a.flex_row, a.gap_sm]}> + {content} + <Button + label={_(msg`Remove embed`)} + onPress={() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setEmbed(undefined) + }} + size="tiny" + variant="solid" + color="secondary" + shape="round"> + <ButtonIcon icon={X} /> + </Button> + </View> + ) +} diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index d6aa06a1c..e6f657b49 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<FlatList>() @@ -277,25 +281,10 @@ export function MessagesList({ rt.detectFacetsWithoutResolution() let embed: AppBskyEmbedRecord.Main | undefined - // find the first link facet that is a link to a post - const postLinkFacet = rt.facets?.find(facet => { - return facet.features.find(feature => { - if (AppBskyRichtextFacet.isLink(feature)) { - return isBskyPostUrl(feature.uri) - } - return false - }) - }) - - // if we found a post link, get the post and embed it - if (postLinkFacet) { - const postLink = postLinkFacet.features.find( - AppBskyRichtextFacet.isLink, - ) - if (!postLink) return + if (embedUri) { try { - const post = await getPostAsQuote(getPost, postLink.uri) + const post = await getPost({uri: embedUri}) if (post) { embed = { $type: 'app.bsky.embed.record', @@ -305,24 +294,43 @@ export function MessagesList({ }, } - // remove the post link from the text - rt.delete( - postLinkFacet.index.byteStart, - postLinkFacet.index.byteEnd, - ) - - // 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 && <ChatEmptyPill />} - <MessageInput onSendMessage={onSendMessage} /> + <MessageInput + onSendMessage={onSendMessage} + hasEmbed={!!embedUri} + setEmbed={setEmbed}> + <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> + </MessageInput> </> )} </KeyboardStickyView> diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index 7c67c59d3..0b1fe2a95 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 9850124c9..de2605b5a 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 f27628d69..794f48eb1 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<AppBskyFeedDefs.PostView>({ queryKey: RQKEY(uri || ''), async queryFn() { - const res = await agent.getPosts({uris: [uri!]}) + const urip = new AtUri(uri!) + + if (!urip.host.startsWith('did:')) { + const res = await agent.resolveHandle({ + handle: urip.host, + }) + urip.host = res.data.did + } + + const res = await agent.getPosts({uris: [urip.toString()]}) if (res.success && res.data.posts[0]) { return res.data.posts[0] } @@ -47,7 +56,7 @@ export function useGetPost() { } const res = await agent.getPosts({ - uris: [urip.toString()!], + uris: [urip.toString()], }) if (res.success && res.data.posts[0]) { diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index a5cc60fd8..4b50946a4 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 style={pal.textLight}>{text}</Text>} - {images && images?.length > 0 && ( + {images && images.length > 0 && ( <ImageHorzList images={images} style={styles.additionalPostImages} /> )} </> diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index cd82ec98f..945cf5e59 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<NavigationProp>() const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const reportDialogControl = useReportDialogControl() const deletePromptControl = useDialogControl() const hidePromptControl = useDialogControl() const loggedOutWarningPromptControl = useDialogControl() const embedPostControl = useDialogControl() + const sendViaChatControl = useDialogControl() const rootUri = record.reply?.root?.uri || postUri const isThreadMuted = mutedThreads.includes(rootUri) @@ -229,6 +232,16 @@ let PostDropdownBtn = ({ Toast.show('Feedback sent!') }, [feedFeedback, postUri, postFeedContext]) + const onSelectChatToShareTo = React.useCallback( + (conversation: string) => { + navigation.navigate('MessagesConversation', { + conversation, + embed: postUri, + }) + }, + [navigation, postUri], + ) + const canEmbed = isWeb && gtMobile && !hideInPWI return ( @@ -280,6 +293,18 @@ let PostDropdownBtn = ({ </> )} + {hasSession && ( + <Menu.Item + testID="postDropdownSendViaDMBtn" + label={_(msg`Send via direct message`)} + onPress={sendViaChatControl.open}> + <Menu.ItemText> + <Trans>Send via direct message</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Send} position="right" /> + </Menu.Item> + )} + <Menu.Item testID="postDropdownShareBtn" label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} @@ -449,6 +474,11 @@ let PostDropdownBtn = ({ timestamp={timestamp} /> )} + + <SendViaChatDialog + control={sendViaChatControl} + onSelectChat={onSelectChatToShareTo} + /> </EventStopper> ) } diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx index e37f8af1b..12eef14f7 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, }, }) |