[Chat] Add chat invite as message embed type (#10728)
This commit is contained in:
@@ -20,7 +20,7 @@ import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||
import {isBskyChatInviteUrl, isBskyPostUrl} from '#/lib/strings/url-helpers'
|
||||
import {useEmail} from '#/state/email-verification'
|
||||
import {
|
||||
useMessageDraft,
|
||||
@@ -233,7 +233,11 @@ export function MessageComposer({
|
||||
}}
|
||||
onChange={handleChange}
|
||||
onFacetCommitted={facet => {
|
||||
if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
|
||||
if (
|
||||
facet.type === 'url' &&
|
||||
(isBskyPostUrl(facet.value) ||
|
||||
isBskyChatInviteUrl(facet.value))
|
||||
) {
|
||||
setEmbed(facet.value)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from '#/lib/routes/types'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyChatInviteUrl,
|
||||
isBskyPostUrl,
|
||||
makeRecordUri,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
@@ -26,6 +28,7 @@ import {usePostQuery} from '#/state/queries/post'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as MediaPreview from '#/components/MediaPreview'
|
||||
@@ -35,35 +38,56 @@ import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
/**
|
||||
* The embed staged in the message composer. A message can carry at most one
|
||||
* embed: either a quoted post or a group chat invite link.
|
||||
*/
|
||||
export type MessageEmbedState =
|
||||
| {type: 'post'; uri: string}
|
||||
| {type: 'invite'; code: string}
|
||||
|
||||
export function useMessageEmbed() {
|
||||
const route =
|
||||
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const embedFromParams = route.params.embed
|
||||
|
||||
const [embedUri, setEmbedUri] = useState(embedFromParams)
|
||||
const [embed, setEmbedState] = useState<MessageEmbedState | undefined>(
|
||||
embedFromParams ? {type: 'post', uri: embedFromParams} : undefined,
|
||||
)
|
||||
|
||||
if (embedFromParams && embedUri !== embedFromParams) {
|
||||
setEmbedUri(embedFromParams)
|
||||
if (embedFromParams && embed?.type !== 'post') {
|
||||
setEmbedState({type: 'post', uri: embedFromParams})
|
||||
}
|
||||
|
||||
return {
|
||||
embedUri,
|
||||
embed,
|
||||
setEmbed: useCallback(
|
||||
(embedUrl: string | undefined) => {
|
||||
if (!embedUrl) {
|
||||
// Only the post embed is reflected in the route param (used by the
|
||||
// share-to-DM intent flow); invites are local-only.
|
||||
navigation.setParams({embed: ''})
|
||||
setEmbedUri(undefined)
|
||||
setEmbedState(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)
|
||||
if (isBskyChatInviteUrl(embedUrl)) {
|
||||
const code = getChatInviteCodeFromUrl(embedUrl)
|
||||
if (code) {
|
||||
setEmbedState({type: 'invite', code})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setEmbedUri(uri)
|
||||
if (isBskyPostUrl(embedUrl)) {
|
||||
const url = convertBskyAppUrlIfNeeded(embedUrl)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
setEmbedState({type: 'post', uri})
|
||||
}
|
||||
},
|
||||
[embedFromParams, navigation],
|
||||
),
|
||||
@@ -81,7 +105,10 @@ export function useExtractEmbedFromFacets(
|
||||
|
||||
for (const facet of rt.facets ?? []) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature) && isBskyPostUrl(feature.uri)) {
|
||||
if (
|
||||
AppBskyRichtextFacet.isLink(feature) &&
|
||||
(isBskyPostUrl(feature.uri) || isBskyChatInviteUrl(feature.uri))
|
||||
) {
|
||||
uriFromFacet = feature.uri
|
||||
break
|
||||
}
|
||||
@@ -96,16 +123,40 @@ export function useExtractEmbedFromFacets(
|
||||
}
|
||||
|
||||
export function MessageInputEmbed({
|
||||
embedUri,
|
||||
embed,
|
||||
setEmbed,
|
||||
}: {
|
||||
embedUri: string | undefined
|
||||
embed: MessageEmbedState | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
}) {
|
||||
const onRemove = useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}, [setEmbed])
|
||||
|
||||
if (!embed) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (embed.type) {
|
||||
case 'post':
|
||||
return <MessageInputPostEmbed uri={embed.uri} onRemove={onRemove} />
|
||||
case 'invite':
|
||||
return <MessageInputInviteEmbed code={embed.code} onRemove={onRemove} />
|
||||
}
|
||||
}
|
||||
|
||||
function MessageInputPostEmbed({
|
||||
uri,
|
||||
onRemove,
|
||||
}: {
|
||||
uri: string
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const {data: post, status} = usePostQuery(embedUri)
|
||||
const {data: post, status} = usePostQuery(uri)
|
||||
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = useMemo(
|
||||
@@ -134,15 +185,6 @@ export function MessageInputEmbed({
|
||||
return {rt: undefined, record: undefined}
|
||||
}, [post])
|
||||
|
||||
if (!embedUri) {
|
||||
return null
|
||||
}
|
||||
|
||||
const onRemove = () => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setEmbed(undefined)
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'pending': {
|
||||
return (
|
||||
@@ -220,6 +262,71 @@ export function MessageInputEmbed({
|
||||
}
|
||||
}
|
||||
|
||||
function MessageInputInviteEmbed({
|
||||
code,
|
||||
onRemove,
|
||||
}: {
|
||||
code: string
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (
|
||||
<ChatInvite.Root code={code}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.border_contrast_high,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.p_sm,
|
||||
a.mt_sm,
|
||||
a.mx_sm,
|
||||
]}>
|
||||
<MessageInputInviteEmbedBody />
|
||||
<Button
|
||||
label={l`Remove embed`}
|
||||
onPress={onRemove}
|
||||
style={[
|
||||
a.absolute,
|
||||
{top: 10, right: 8},
|
||||
a.px_2xs,
|
||||
{transform: [{translateY: -2}]},
|
||||
]}
|
||||
hitSlop={HITSLOP_20}>
|
||||
<XIcon size="xs" style={t.atoms.text_contrast_high} />
|
||||
</Button>
|
||||
</View>
|
||||
</ChatInvite.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageInputInviteEmbedBody() {
|
||||
const t = useTheme()
|
||||
const {loading, preview} = ChatInvite.useChatInvite()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Loader />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return (
|
||||
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Text style={[a.text_center, t.atoms.text_contrast_medium, a.italic]}>
|
||||
<Trans>Could not load invite</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return <ChatInvite.Card size="small" />
|
||||
}
|
||||
|
||||
function SimpleContainer({
|
||||
children,
|
||||
onRemove,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type AppBskyEmbedRecord,
|
||||
AppBskyRichtextFacet,
|
||||
ChatBskyConvoDefs,
|
||||
type ChatBskyEmbedJoinLink,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect'
|
||||
@@ -37,6 +38,7 @@ import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
getChatInviteCodeFromUrl,
|
||||
isBskyPostUrl,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {logger} from '#/logger'
|
||||
@@ -46,9 +48,10 @@ import {
|
||||
useConvoActive,
|
||||
} from '#/state/messages/convo'
|
||||
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
|
||||
import {useGetJoinLinkPreview} from '#/state/queries/join-links'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
||||
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
||||
@@ -131,8 +134,10 @@ export function MessagesList({
|
||||
const ax = useAnalytics()
|
||||
const convoState = useConvoActive()
|
||||
const agent = useAgent()
|
||||
const {hasSession} = useSession()
|
||||
const getPost = useGetPost()
|
||||
const {embedUri, setEmbed} = useMessageEmbed()
|
||||
const getJoinLinkPreview = useGetJoinLinkPreview()
|
||||
const {embed: messageEmbed, setEmbed} = useMessageEmbed()
|
||||
const t = useTheme()
|
||||
|
||||
const textInputId = 'chat-input-' + useId()
|
||||
@@ -348,12 +353,38 @@ export function MessagesList({
|
||||
// we want to remove the post link from the text, re-trim, then detect facets
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
let embed: $Typed<AppBskyEmbedRecord.Main> | undefined
|
||||
let embedView: $Typed<AppBskyEmbedRecord.View> | undefined
|
||||
let embed:
|
||||
| $Typed<AppBskyEmbedRecord.Main>
|
||||
| $Typed<ChatBskyEmbedJoinLink.Main>
|
||||
| undefined
|
||||
let embedView:
|
||||
| $Typed<AppBskyEmbedRecord.View>
|
||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||
| undefined
|
||||
|
||||
if (embedUri) {
|
||||
// Find the embedded link facet and, if it's at the start or end of the
|
||||
// message, remove it from the text (the embed card replaces it).
|
||||
const stripLinkFacet = (predicate: (uri: string) => boolean) => {
|
||||
const linkFacet = rt.facets?.find(facet =>
|
||||
facet.features.find(
|
||||
feature =>
|
||||
AppBskyRichtextFacet.isLink(feature) && predicate(feature.uri),
|
||||
),
|
||||
)
|
||||
if (linkFacet) {
|
||||
const isAtStart = linkFacet.index.byteStart === 0
|
||||
const isAtEnd =
|
||||
linkFacet.index.byteEnd === rt.unicodeText.graphemeLength
|
||||
if (isAtStart || isAtEnd) {
|
||||
rt.delete(linkFacet.index.byteStart, linkFacet.index.byteEnd)
|
||||
}
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
}
|
||||
}
|
||||
|
||||
if (messageEmbed?.type === 'post') {
|
||||
try {
|
||||
const post = await getPost({uri: embedUri})
|
||||
const post = await getPost({uri: messageEmbed.uri})
|
||||
if (post) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.record',
|
||||
@@ -368,42 +399,34 @@ export function MessagesList({
|
||||
record: createEmbedViewRecordFromPost(post),
|
||||
}
|
||||
|
||||
// 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
|
||||
})
|
||||
stripLinkFacet(uri => {
|
||||
if (!isBskyPostUrl(uri)) return false
|
||||
const url = convertBskyAppUrlIfNeeded(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)
|
||||
})
|
||||
|
||||
if (postLinkFacet) {
|
||||
const isAtStart = postLinkFacet.index.byteStart === 0
|
||||
const isAtEnd =
|
||||
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
|
||||
|
||||
// remove the post link from the text
|
||||
if (isAtStart || isAtEnd) {
|
||||
rt.delete(
|
||||
postLinkFacet.index.byteStart,
|
||||
postLinkFacet.index.byteEnd,
|
||||
)
|
||||
}
|
||||
|
||||
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get post as quote for DM', {error})
|
||||
}
|
||||
} else if (messageEmbed?.type === 'invite') {
|
||||
const code = messageEmbed.code
|
||||
embed = {
|
||||
$type: 'chat.bsky.embed.joinLink',
|
||||
code,
|
||||
}
|
||||
|
||||
const joinLinkPreview = await getJoinLinkPreview({code, hasSession})
|
||||
if (joinLinkPreview) {
|
||||
embedView = {
|
||||
$type: 'chat.bsky.embed.joinLink#view',
|
||||
joinLinkPreview,
|
||||
}
|
||||
}
|
||||
|
||||
stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code)
|
||||
}
|
||||
|
||||
await rt.detectFacets(agent)
|
||||
@@ -424,7 +447,16 @@ export function MessagesList({
|
||||
embedView,
|
||||
)
|
||||
},
|
||||
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
|
||||
[
|
||||
agent,
|
||||
convoState,
|
||||
messageEmbed,
|
||||
getPost,
|
||||
getJoinLinkPreview,
|
||||
hasSession,
|
||||
hasScrolled,
|
||||
setHasScrolled,
|
||||
],
|
||||
)
|
||||
|
||||
const scrollToEndOnPress = useCallback(() => {
|
||||
@@ -595,11 +627,11 @@ export function MessagesList({
|
||||
onSendMessage={(message: string) =>
|
||||
void onSendMessage(message)
|
||||
}
|
||||
hasEmbed={!!embedUri}
|
||||
hasEmbed={!!messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
embed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageComposer>
|
||||
@@ -607,11 +639,11 @@ export function MessagesList({
|
||||
<MessageInput
|
||||
textInputId={textInputId}
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
hasEmbed={!!messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
embed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageInput>
|
||||
|
||||
Reference in New Issue
Block a user