[🐴] Option to share via chat in post dropdown (#4231)
* add send via chat button to post dropdown (cherry picked from commit d8458c0bc344f993266f7bc7e325d47e40619648) * let usePostQuery take uris with DIDs (cherry picked from commit 16b577ce749fd07e1d5f8461e8ca71c5b874a936) * add embed preview in composer (cherry picked from commit 795ceb98d55b6a3ab5b83187a582f9656d71db69) * rm log (cherry picked from commit 374d6b8869459f08d8442a3a47d67149e8d9ddd4) * remove params properly, or at least as close to (cherry picked from commit c20e0062c2ca4d9c2b28324eee5e713a1a3ab251) * show images in preview (cherry picked from commit 5bb617a3ce00f67bfc79784b2f81ef8dcb5bfc25) * Register embed immediately (cherry picked from commit ee120d5438a2c91c8980288665576d6a29b4c7e7) * Add hover to match embeds (cherry picked from commit 5297a5b06e499f46a9f6da510124610005db2448) * Update post dropdown copy (cherry picked from commit bc7e9f6a4303926a53c5c889f1f1b136faf20491) * Embed preview style tweaks (cherry picked from commit 9e3ccb0f25ac2f3ce6af538bb29112a3e96e01b1) * use hydrated posts from API and just use postembed component (cherry picked from commit cc0b84db87ca812d76cc69f46170ae84cfdde4ef) * fix type error (cherry picked from commit 9c49b940e1248e8a7c3b64190c5cb20750043619) * undo needless export (cherry picked from commit 1186701c997c50c0b29a809637cb9bc061b8c0a0) * fix overflow (cherry picked from commit 8868d5075062d0199c8ef6946fabde27e46ea378) --------- Co-authored-by: Eric Bailey <git@esb.lol>zio/stable
parent
22e1eb18c8
commit
cd3b502b34
|
@ -49,7 +49,7 @@
|
||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.12.13",
|
"@atproto/api": "^0.12.14",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
|
|
@ -82,7 +82,7 @@ let MessageItem = ({
|
||||||
return (
|
return (
|
||||||
<View style={[isFromSelf ? a.mr_md : a.ml_md]}>
|
<View style={[isFromSelf ? a.mr_md : a.ml_md]}>
|
||||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||||
{AppBskyEmbedRecord.isMain(message.embed) && (
|
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||||
<MessageItemEmbed embed={message.embed} />
|
<MessageItemEmbed embed={message.embed} />
|
||||||
)}
|
)}
|
||||||
{rt.text.length > 0 && (
|
{rt.text.length > 0 && (
|
||||||
|
|
|
@ -1,108 +1,21 @@
|
||||||
import React, {useMemo} from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {
|
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||||
AppBskyEmbedRecord,
|
|
||||||
AppBskyFeedPost,
|
|
||||||
AtUri,
|
|
||||||
RichText as RichTextAPI,
|
|
||||||
} 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 {PostEmbeds} from '#/view/com/util/post-embeds'
|
||||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
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 = ({
|
let MessageItemEmbed = ({
|
||||||
embed,
|
embed,
|
||||||
}: {
|
}: {
|
||||||
embed: AppBskyEmbedRecord.Main
|
embed: AppBskyEmbedRecord.View
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
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 (
|
return (
|
||||||
<Link to={itemHref}>
|
<View style={[a.my_xs, t.atoms.bg, a.rounded_md, {flexBasis: 0}]}>
|
||||||
<View
|
<PostEmbeds embed={embed} />
|
||||||
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>
|
</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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageItemEmbed = React.memo(MessageItemEmbed)
|
MessageItemEmbed = React.memo(MessageItemEmbed)
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -16,23 +16,18 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
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 {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {logEvent} from 'lib/statsig/statsig'
|
|
||||||
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
|
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 {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
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 {canBeMessaged} from '#/components/dms/util'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
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 {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
@ -57,55 +52,228 @@ type Item =
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewChat({
|
export function SearchablePeopleList({
|
||||||
control,
|
title,
|
||||||
onNewChat,
|
onSelectChat,
|
||||||
}: {
|
}: {
|
||||||
control: Dialog.DialogControlProps
|
title: string
|
||||||
onNewChat: (chatId: string) => void
|
onSelectChat: (did: string) => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const inputRef = useRef<TextInputType>(null)
|
||||||
|
|
||||||
const {mutate: createChat} = useGetConvoForMembers({
|
const [searchText, setSearchText] = useState('')
|
||||||
onSuccess: data => {
|
|
||||||
onNewChat(data.convo.id)
|
|
||||||
|
|
||||||
if (!data.convo.lastMessage) {
|
const {
|
||||||
logEvent('chat:create', {logContext: 'NewChatDialog'})
|
data: results,
|
||||||
}
|
isError,
|
||||||
logEvent('chat:open', {logContext: 'NewChatDialog'})
|
isFetching,
|
||||||
},
|
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||||
onError: error => {
|
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||||
Toast.show(error.message)
|
|
||||||
},
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const onCreateChat = useCallback(
|
_items = _items.sort(a => {
|
||||||
(did: string) => {
|
// @ts-ignore
|
||||||
control.close(() => createChat([did]))
|
return a.enabled ? -1 : 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (follows) {
|
||||||
|
for (const page of follows.pages) {
|
||||||
|
for (const profile of page.follows) {
|
||||||
|
_items.push({
|
||||||
|
type: 'profile',
|
||||||
|
key: profile.did,
|
||||||
|
enabled: canBeMessaged(profile),
|
||||||
|
profile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_items = _items.sort(a => {
|
||||||
|
// @ts-ignore
|
||||||
|
return a.enabled ? -1 : 1
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Array(10)
|
||||||
|
.fill(0)
|
||||||
|
.forEach((_, i) => {
|
||||||
|
_items.push({
|
||||||
|
type: 'placeholder',
|
||||||
|
key: i + '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _items
|
||||||
|
}, [_, searchText, results, isError, currentAccount?.did, follows])
|
||||||
|
|
||||||
|
if (searchText && !isFetching && !items.length && !isError) {
|
||||||
|
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItems = useCallback(
|
||||||
|
({item}: {item: Item}) => {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'profile': {
|
||||||
|
return (
|
||||||
|
<ProfileCard
|
||||||
|
key={item.key}
|
||||||
|
enabled={item.enabled}
|
||||||
|
profile={item.profile}
|
||||||
|
moderationOpts={moderationOpts!}
|
||||||
|
onPress={onSelectChat}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'placeholder': {
|
||||||
|
return <ProfileCardSkeleton key={item.key} />
|
||||||
|
}
|
||||||
|
case 'empty': {
|
||||||
|
return <Empty key={item.key} message={item.message} />
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[control, createChat],
|
[moderationOpts, onSelectChat],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
useLayoutEffect(() => {
|
||||||
<>
|
if (isWeb) {
|
||||||
<FAB
|
setImmediate(() => {
|
||||||
testID="newChatFAB"
|
inputRef?.current?.focus()
|
||||||
onPress={control.open}
|
})
|
||||||
icon={<Plus size="lg" fill={t.palette.white} />}
|
}
|
||||||
accessibilityRole="button"
|
}, [])
|
||||||
accessibilityLabel={_(msg`New chat`)}
|
|
||||||
accessibilityHint=""
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Dialog.Outer
|
const listHeader = useMemo(() => {
|
||||||
control={control}
|
return (
|
||||||
testID="newChatDialog"
|
<View
|
||||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
style={[
|
||||||
<SearchablePeopleList onCreateChat={onCreateChat} />
|
a.relative,
|
||||||
</Dialog.Outer>
|
a.pt_md,
|
||||||
</>
|
a.pb_xs,
|
||||||
|
a.px_lg,
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
native([a.pt_lg]),
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
native(a.align_center),
|
||||||
|
a.justify_center,
|
||||||
|
{height: 32},
|
||||||
|
]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.z_20,
|
||||||
|
native({
|
||||||
|
left: -7,
|
||||||
|
}),
|
||||||
|
web({
|
||||||
|
right: -4,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
onPress={() => control.close()}>
|
||||||
|
{isWeb ? (
|
||||||
|
<X size="md" fill={t.palette.contrast_500} />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft size="md" fill={t.palette.contrast_500} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.z_10,
|
||||||
|
a.text_lg,
|
||||||
|
a.font_bold,
|
||||||
|
a.leading_tight,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
|
||||||
|
<SearchInput
|
||||||
|
inputRef={inputRef}
|
||||||
|
value={searchText}
|
||||||
|
onChangeText={text => {
|
||||||
|
setSearchText(text)
|
||||||
|
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||||
|
}}
|
||||||
|
onEscape={control.close}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.text_contrast_high,
|
||||||
|
t.palette.contrast_500,
|
||||||
|
_,
|
||||||
|
title,
|
||||||
|
searchText,
|
||||||
|
control,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.InnerFlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={items}
|
||||||
|
renderItem={renderItems}
|
||||||
|
ListHeaderComponent={listHeader}
|
||||||
|
stickyHeaderIndices={[0]}
|
||||||
|
keyExtractor={(item: Item) => item.key}
|
||||||
|
style={[
|
||||||
|
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||||
|
native({
|
||||||
|
height: '100%',
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
marginTop: 0,
|
||||||
|
paddingTop: 0,
|
||||||
|
borderTopLeftRadius: 40,
|
||||||
|
borderTopRightRadius: 40,
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -293,217 +461,3 @@ function SearchInput({
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchablePeopleList({
|
|
||||||
onCreateChat,
|
|
||||||
}: {
|
|
||||||
onCreateChat: (did: string) => void
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
|
||||||
const {_} = useLingui()
|
|
||||||
const moderationOpts = useModerationOpts()
|
|
||||||
const control = Dialog.useDialogContext()
|
|
||||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
|
||||||
const {currentAccount} = useSession()
|
|
||||||
const inputRef = useRef<TextInputType>(null)
|
|
||||||
|
|
||||||
const [searchText, setSearchText] = useState('')
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: results,
|
|
||||||
isError,
|
|
||||||
isFetching,
|
|
||||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
|
||||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
let _items: Item[] = []
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
_items.push({
|
|
||||||
type: 'empty',
|
|
||||||
key: 'empty',
|
|
||||||
message: _(msg`We're having network issues, try again`),
|
|
||||||
})
|
|
||||||
} else if (searchText.length) {
|
|
||||||
if (results?.length) {
|
|
||||||
for (const profile of results) {
|
|
||||||
if (profile.did === currentAccount?.did) continue
|
|
||||||
_items.push({
|
|
||||||
type: 'profile',
|
|
||||||
key: profile.did,
|
|
||||||
enabled: canBeMessaged(profile),
|
|
||||||
profile,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
_items = _items.sort(a => {
|
|
||||||
// @ts-ignore
|
|
||||||
return a.enabled ? -1 : 1
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (follows) {
|
|
||||||
for (const page of follows.pages) {
|
|
||||||
for (const profile of page.follows) {
|
|
||||||
_items.push({
|
|
||||||
type: 'profile',
|
|
||||||
key: profile.did,
|
|
||||||
enabled: canBeMessaged(profile),
|
|
||||||
profile,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_items = _items.sort(a => {
|
|
||||||
// @ts-ignore
|
|
||||||
return a.enabled ? -1 : 1
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Array(10)
|
|
||||||
.fill(0)
|
|
||||||
.forEach((_, i) => {
|
|
||||||
_items.push({
|
|
||||||
type: 'placeholder',
|
|
||||||
key: i + '',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return _items
|
|
||||||
}, [_, searchText, results, isError, currentAccount?.did, follows])
|
|
||||||
|
|
||||||
if (searchText && !isFetching && !items.length && !isError) {
|
|
||||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderItems = useCallback(
|
|
||||||
({item}: {item: Item}) => {
|
|
||||||
switch (item.type) {
|
|
||||||
case 'profile': {
|
|
||||||
return (
|
|
||||||
<ProfileCard
|
|
||||||
key={item.key}
|
|
||||||
enabled={item.enabled}
|
|
||||||
profile={item.profile}
|
|
||||||
moderationOpts={moderationOpts!}
|
|
||||||
onPress={onCreateChat}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
case 'placeholder': {
|
|
||||||
return <ProfileCardSkeleton key={item.key} />
|
|
||||||
}
|
|
||||||
case 'empty': {
|
|
||||||
return <Empty key={item.key} message={item.message} />
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[moderationOpts, onCreateChat],
|
|
||||||
)
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (isWeb) {
|
|
||||||
setImmediate(() => {
|
|
||||||
inputRef?.current?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const listHeader = useMemo(() => {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.relative,
|
|
||||||
a.pt_md,
|
|
||||||
a.pb_xs,
|
|
||||||
a.px_lg,
|
|
||||||
a.border_b,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
t.atoms.bg,
|
|
||||||
native([a.pt_lg]),
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.relative,
|
|
||||||
native(a.align_center),
|
|
||||||
a.justify_center,
|
|
||||||
{height: 32},
|
|
||||||
]}>
|
|
||||||
<Button
|
|
||||||
label={_(msg`Close`)}
|
|
||||||
size="small"
|
|
||||||
shape="round"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.z_20,
|
|
||||||
native({
|
|
||||||
left: -7,
|
|
||||||
}),
|
|
||||||
web({
|
|
||||||
right: -4,
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
onPress={() => control.close()}>
|
|
||||||
{isWeb ? (
|
|
||||||
<X size="md" fill={t.palette.contrast_500} />
|
|
||||||
) : (
|
|
||||||
<ChevronLeft size="md" fill={t.palette.contrast_500} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.z_10,
|
|
||||||
a.text_lg,
|
|
||||||
a.font_bold,
|
|
||||||
a.leading_tight,
|
|
||||||
t.atoms.text_contrast_high,
|
|
||||||
]}>
|
|
||||||
<Trans>Start a new chat</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
|
|
||||||
<SearchInput
|
|
||||||
inputRef={inputRef}
|
|
||||||
value={searchText}
|
|
||||||
onChangeText={text => {
|
|
||||||
setSearchText(text)
|
|
||||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
|
||||||
}}
|
|
||||||
onEscape={control.close}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}, [t, _, control, searchText])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.InnerFlatList
|
|
||||||
ref={listRef}
|
|
||||||
data={items}
|
|
||||||
renderItem={renderItems}
|
|
||||||
ListHeaderComponent={listHeader}
|
|
||||||
stickyHeaderIndices={[0]}
|
|
||||||
keyExtractor={(item: Item) => item.key}
|
|
||||||
style={[
|
|
||||||
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
|
||||||
native({
|
|
||||||
height: '100%',
|
|
||||||
paddingHorizontal: 0,
|
|
||||||
marginTop: 0,
|
|
||||||
paddingTop: 0,
|
|
||||||
borderTopLeftRadius: 40,
|
|
||||||
borderTopRightRadius: 40,
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
|
||||||
keyboardDismissMode="on-drag"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -38,7 +38,7 @@ export type CommonNavigatorParams = {
|
||||||
AccessibilitySettings: undefined
|
AccessibilitySettings: undefined
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
MessagesConversation: {conversation: string}
|
MessagesConversation: {conversation: string; embed?: string}
|
||||||
MessagesSettings: undefined
|
MessagesSettings: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -130,10 +130,14 @@ export type LogEvents = {
|
||||||
| 'AvatarButton'
|
| 'AvatarButton'
|
||||||
}
|
}
|
||||||
'chat:create': {
|
'chat:create': {
|
||||||
logContext: 'ProfileHeader' | 'NewChatDialog'
|
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
|
||||||
}
|
}
|
||||||
'chat:open': {
|
'chat:open': {
|
||||||
logContext: 'ProfileHeader' | 'NewChatDialog' | 'ChatsList'
|
logContext:
|
||||||
|
| 'ProfileHeader'
|
||||||
|
| 'NewChatDialog'
|
||||||
|
| 'ChatsList'
|
||||||
|
| 'SendViaChatDialog'
|
||||||
}
|
}
|
||||||
|
|
||||||
'test:all:always': {}
|
'test:all:always': {}
|
||||||
|
|
|
@ -27,13 +27,20 @@ import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||||
|
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||||
|
|
||||||
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
|
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
|
||||||
|
|
||||||
export function MessageInput({
|
export function MessageInput({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
hasEmbed,
|
||||||
|
setEmbed,
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
onSendMessage: (message: string) => void
|
onSendMessage: (message: string) => void
|
||||||
|
hasEmbed: boolean
|
||||||
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
@ -53,9 +60,10 @@ export function MessageInput({
|
||||||
const inputRef = useAnimatedRef<TextInput>()
|
const inputRef = useAnimatedRef<TextInput>()
|
||||||
|
|
||||||
useSaveMessageDraft(message)
|
useSaveMessageDraft(message)
|
||||||
|
useExtractEmbedFromFacets(message, setEmbed)
|
||||||
|
|
||||||
const onSubmit = React.useCallback(() => {
|
const onSubmit = React.useCallback(() => {
|
||||||
if (message.trim() === '') {
|
if (!hasEmbed && message.trim() === '') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||||
|
@ -66,13 +74,23 @@ export function MessageInput({
|
||||||
onSendMessage(message)
|
onSendMessage(message)
|
||||||
playHaptic()
|
playHaptic()
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
setEmbed(undefined)
|
||||||
|
|
||||||
// Pressing the send button causes the text input to lose focus, so we need to
|
// Pressing the send button causes the text input to lose focus, so we need to
|
||||||
// re-focus it after sending
|
// re-focus it after sending
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}, 100)
|
}, 100)
|
||||||
}, [message, clearDraft, onSendMessage, playHaptic, _, inputRef])
|
}, [
|
||||||
|
hasEmbed,
|
||||||
|
message,
|
||||||
|
clearDraft,
|
||||||
|
onSendMessage,
|
||||||
|
playHaptic,
|
||||||
|
setEmbed,
|
||||||
|
_,
|
||||||
|
inputRef,
|
||||||
|
])
|
||||||
|
|
||||||
useFocusedInputHandler(
|
useFocusedInputHandler(
|
||||||
{
|
{
|
||||||
|
@ -101,6 +119,7 @@ export function MessageInput({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
|
<View style={[a.px_md, a.pb_sm, a.pt_xs]}>
|
||||||
|
{children}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
|
|
|
@ -16,11 +16,18 @@ import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||||
|
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||||
|
|
||||||
export function MessageInput({
|
export function MessageInput({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
hasEmbed,
|
||||||
|
setEmbed,
|
||||||
|
children,
|
||||||
}: {
|
}: {
|
||||||
onSendMessage: (message: string) => void
|
onSendMessage: (message: string) => void
|
||||||
|
hasEmbed: boolean
|
||||||
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
@ -35,7 +42,7 @@ export function MessageInput({
|
||||||
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
||||||
|
|
||||||
const onSubmit = React.useCallback(() => {
|
const onSubmit = React.useCallback(() => {
|
||||||
if (message.trim() === '') {
|
if (!hasEmbed && message.trim() === '') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||||
|
@ -45,7 +52,8 @@ export function MessageInput({
|
||||||
clearDraft()
|
clearDraft()
|
||||||
onSendMessage(message)
|
onSendMessage(message)
|
||||||
setMessage('')
|
setMessage('')
|
||||||
}, [message, onSendMessage, _, clearDraft])
|
setEmbed(undefined)
|
||||||
|
}, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed])
|
||||||
|
|
||||||
const onKeyDown = React.useCallback(
|
const onKeyDown = React.useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
@ -87,9 +95,11 @@ export function MessageInput({
|
||||||
)
|
)
|
||||||
|
|
||||||
useSaveMessageDraft(message)
|
useSaveMessageDraft(message)
|
||||||
|
useExtractEmbedFromFacets(message, setEmbed)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={a.p_sm}>
|
<View style={a.p_sm}>
|
||||||
|
{children}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -15,9 +15,11 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
|
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
|
||||||
|
|
||||||
import {getPostAsQuote} from '#/lib/link-meta/bsky'
|
|
||||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
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 {logger} from '#/logger'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {isConvoActive, useConvoActive} from '#/state/messages/convo'
|
import {isConvoActive, useConvoActive} from '#/state/messages/convo'
|
||||||
|
@ -36,6 +38,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
|
||||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
||||||
|
|
||||||
function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
||||||
return (
|
return (
|
||||||
|
@ -85,6 +88,7 @@ export function MessagesList({
|
||||||
const convoState = useConvoActive()
|
const convoState = useConvoActive()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const getPost = useGetPost()
|
const getPost = useGetPost()
|
||||||
|
const {embedUri, setEmbed} = useMessageEmbed()
|
||||||
|
|
||||||
const flatListRef = useAnimatedRef<FlatList>()
|
const flatListRef = useAnimatedRef<FlatList>()
|
||||||
|
|
||||||
|
@ -277,25 +281,10 @@ export function MessagesList({
|
||||||
rt.detectFacetsWithoutResolution()
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
let embed: AppBskyEmbedRecord.Main | undefined
|
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 {
|
try {
|
||||||
const post = await getPostAsQuote(getPost, postLink.uri)
|
const post = await getPost({uri: embedUri})
|
||||||
if (post) {
|
if (post) {
|
||||||
embed = {
|
embed = {
|
||||||
$type: 'app.bsky.embed.record',
|
$type: 'app.bsky.embed.record',
|
||||||
|
@ -305,6 +294,24 @@ export function MessagesList({
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// remove the post link from the text
|
||||||
rt.delete(
|
rt.delete(
|
||||||
postLinkFacet.index.byteStart,
|
postLinkFacet.index.byteStart,
|
||||||
|
@ -325,6 +332,7 @@ export function MessagesList({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get post as quote for DM', {error})
|
logger.error('Failed to get post as quote for DM', {error})
|
||||||
}
|
}
|
||||||
|
@ -345,7 +353,7 @@ export function MessagesList({
|
||||||
embed,
|
embed,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[agent, convoState, getPost, hasScrolled, setHasScrolled],
|
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
|
||||||
)
|
)
|
||||||
|
|
||||||
// -- List layout changes (opening emoji keyboard, etc.)
|
// -- List layout changes (opening emoji keyboard, etc.)
|
||||||
|
@ -420,7 +428,12 @@ export function MessagesList({
|
||||||
{isConvoActive(convoState) &&
|
{isConvoActive(convoState) &&
|
||||||
!convoState.isFetchingHistory &&
|
!convoState.isFetchingHistory &&
|
||||||
convoState.items.length === 0 && <ChatEmptyPill />}
|
convoState.items.length === 0 && <ChatEmptyPill />}
|
||||||
<MessageInput onSendMessage={onSendMessage} />
|
<MessageInput
|
||||||
|
onSendMessage={onSendMessage}
|
||||||
|
hasEmbed={!!embedUri}
|
||||||
|
setEmbed={setEmbed}>
|
||||||
|
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||||
|
</MessageInput>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</KeyboardStickyView>
|
</KeyboardStickyView>
|
||||||
|
|
|
@ -21,8 +21,8 @@ import {CenteredView} from '#/view/com/util/Views'
|
||||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
|
import {DialogControlProps, useDialogControl} from '#/components/Dialog'
|
||||||
|
import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
|
||||||
import {MessagesNUX} from '#/components/dms/MessagesNUX'
|
import {MessagesNUX} from '#/components/dms/MessagesNUX'
|
||||||
import {NewChat} from '#/components/dms/NewChatDialog'
|
|
||||||
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
||||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
|
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
|
|
|
@ -1018,6 +1018,7 @@ export class Convo {
|
||||||
key: m.id,
|
key: m.id,
|
||||||
message: {
|
message: {
|
||||||
...m.message,
|
...m.message,
|
||||||
|
embed: undefined,
|
||||||
$type: 'chat.bsky.convo.defs#messageView',
|
$type: 'chat.bsky.convo.defs#messageView',
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
rev: '__fake__',
|
rev: '__fake__',
|
||||||
|
|
|
@ -18,7 +18,16 @@ export function usePostQuery(uri: string | undefined) {
|
||||||
return useQuery<AppBskyFeedDefs.PostView>({
|
return useQuery<AppBskyFeedDefs.PostView>({
|
||||||
queryKey: RQKEY(uri || ''),
|
queryKey: RQKEY(uri || ''),
|
||||||
async queryFn() {
|
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]) {
|
if (res.success && res.data.posts[0]) {
|
||||||
return res.data.posts[0]
|
return res.data.posts[0]
|
||||||
}
|
}
|
||||||
|
@ -47,7 +56,7 @@ export function useGetPost() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await agent.getPosts({
|
const res = await agent.getPosts({
|
||||||
uris: [urip.toString()!],
|
uris: [urip.toString()],
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.success && res.data.posts[0]) {
|
if (res.success && res.data.posts[0]) {
|
||||||
|
|
|
@ -451,7 +451,7 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
||||||
{images && images?.length > 0 && (
|
{images && images.length > 0 && (
|
||||||
<ImageHorzList images={images} style={styles.additionalPostImages} />
|
<ImageHorzList images={images} style={styles.additionalPostImages} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -12,12 +12,12 @@ import {
|
||||||
AtUri,
|
AtUri,
|
||||||
RichText as RichTextAPI,
|
RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
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 {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
import {getTranslatorLink} from '#/locale/helpers'
|
import {getTranslatorLink} from '#/locale/helpers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
@ -37,6 +37,7 @@ import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
import {EmbedDialog} from '#/components/dialogs/Embed'
|
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 {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
||||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
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 {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||||
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
|
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
|
||||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
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 {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||||
|
@ -102,13 +104,14 @@ let PostDropdownBtn = ({
|
||||||
const {hidePost} = useHiddenPostsApi()
|
const {hidePost} = useHiddenPostsApi()
|
||||||
const feedFeedback = useFeedFeedbackContext()
|
const feedFeedback = useFeedFeedbackContext()
|
||||||
const openLink = useOpenLink()
|
const openLink = useOpenLink()
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||||
const reportDialogControl = useReportDialogControl()
|
const reportDialogControl = useReportDialogControl()
|
||||||
const deletePromptControl = useDialogControl()
|
const deletePromptControl = useDialogControl()
|
||||||
const hidePromptControl = useDialogControl()
|
const hidePromptControl = useDialogControl()
|
||||||
const loggedOutWarningPromptControl = useDialogControl()
|
const loggedOutWarningPromptControl = useDialogControl()
|
||||||
const embedPostControl = useDialogControl()
|
const embedPostControl = useDialogControl()
|
||||||
|
const sendViaChatControl = useDialogControl()
|
||||||
|
|
||||||
const rootUri = record.reply?.root?.uri || postUri
|
const rootUri = record.reply?.root?.uri || postUri
|
||||||
const isThreadMuted = mutedThreads.includes(rootUri)
|
const isThreadMuted = mutedThreads.includes(rootUri)
|
||||||
|
@ -229,6 +232,16 @@ let PostDropdownBtn = ({
|
||||||
Toast.show('Feedback sent!')
|
Toast.show('Feedback sent!')
|
||||||
}, [feedFeedback, postUri, postFeedContext])
|
}, [feedFeedback, postUri, postFeedContext])
|
||||||
|
|
||||||
|
const onSelectChatToShareTo = React.useCallback(
|
||||||
|
(conversation: string) => {
|
||||||
|
navigation.navigate('MessagesConversation', {
|
||||||
|
conversation,
|
||||||
|
embed: postUri,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[navigation, postUri],
|
||||||
|
)
|
||||||
|
|
||||||
const canEmbed = isWeb && gtMobile && !hideInPWI
|
const canEmbed = isWeb && gtMobile && !hideInPWI
|
||||||
|
|
||||||
return (
|
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
|
<Menu.Item
|
||||||
testID="postDropdownShareBtn"
|
testID="postDropdownShareBtn"
|
||||||
label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
|
label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
|
||||||
|
@ -449,6 +474,11 @@ let PostDropdownBtn = ({
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SendViaChatDialog
|
||||||
|
control={sendViaChatControl}
|
||||||
|
onSelectChat={onSelectChatToShareTo}
|
||||||
|
/>
|
||||||
</EventStopper>
|
</EventStopper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,11 +27,14 @@ export function ImageHorzList({images, style}: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
flexRow: {flexDirection: 'row'},
|
flexRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 5,
|
||||||
|
},
|
||||||
image: {
|
image: {
|
||||||
width: 100,
|
maxWidth: 100,
|
||||||
height: 100,
|
aspectRatio: 1,
|
||||||
|
flex: 1,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
marginRight: 5,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -34,10 +34,10 @@
|
||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
"@atproto/api@^0.12.13":
|
"@atproto/api@^0.12.14":
|
||||||
version "0.12.13"
|
version "0.12.14"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.13.tgz#269d6c57ea894e23f20b28bd3cbfed944bd28528"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.14.tgz#81252fd166ec8fe950056531e690d563437720fa"
|
||||||
integrity sha512-pRSID6w8AUiZJoCxgctMPRTSGVFHq7wphAnxEbRLBP3OQ1g+BRZUcqFw+e+17Pd3wrc8VImjiD4HCWtCJvCx3w==
|
integrity sha512-ZPh/afoRjFEQDQgMZW2FQiG5CDUifY7SxBqI0zVJUwed8Zi6fqYzGYM8fcDvD8yJfflRCqRxUE72g5fKiA1zAQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/lexicon" "^0.4.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"
|
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af"
|
||||||
integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==
|
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"
|
version "3.22.2"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b"
|
||||||
integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==
|
integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==
|
||||||
|
|
||||||
zod@^3.22.4:
|
zod@^3.21.4, zod@^3.22.4:
|
||||||
version "3.23.8"
|
version "3.23.8"
|
||||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
|
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
|
||||||
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
|
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
|
||||||
|
|
Loading…
Reference in New Issue