[Embeds] "Embed post" post dropdown option (#3513)
* add embed option to post dropdown menu * put embed post button behind a gate * increase line height in dialog * add gate to gate name union * hide embed button if PWI optout * Ungate embed button * Escape HTML, align implementations * Make dialog conditionally rendered * Memoize EmbedDialog * Render dialog lazily --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com>zio/stable
parent
4b3ec55732
commit
4c966e5d6d
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M14.242 3.03a1 1 0 0 1 .728 1.213l-4 16a1 1 0 1 1-1.94-.485l4-16a1 1 0 0 1 1.213-.728ZM6.707 7.293a1 1 0 0 1 0 1.414L3.414 12l3.293 3.293a1 1 0 1 1-1.414 1.414l-4-4a1 1 0 0 1 0-1.414l4-4a1 1 0 0 1 1.414 0Zm10.586 0a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1 0 1.414l-4 4a1 1 0 1 1-1.414-1.414L20.586 12l-3.293-3.293a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 466 B |
|
@ -159,6 +159,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lang = record.langs && record.langs.length > 0 ? record.langs[0] : ''
|
||||||
const profileHref = toShareUrl(
|
const profileHref = toShareUrl(
|
||||||
['/profile', thread.post.author.did].join('/'),
|
['/profile', thread.post.author.did].join('/'),
|
||||||
)
|
)
|
||||||
|
@ -167,10 +168,9 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) {
|
||||||
['/profile', thread.post.author.did, 'post', urip.rkey].join('/'),
|
['/profile', thread.post.author.did, 'post', urip.rkey].join('/'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const lang = record.langs ? record.langs[0] : ''
|
|
||||||
|
|
||||||
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||||
// DO NOT ADD ANY NEW INTERPOLATIOONS BELOW WITHOUT ESCAPING THEM!
|
// DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM!
|
||||||
|
// Also, keep this code synced with the app code in Embed.tsx.
|
||||||
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||||
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
|
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
|
||||||
thread.post.uri,
|
thread.post.uri,
|
||||||
|
|
|
@ -0,0 +1,191 @@
|
||||||
|
import React, {memo, useRef, useState} from 'react'
|
||||||
|
import {TextInput, View} from 'react-native'
|
||||||
|
import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {EMBED_SCRIPT} from '#/lib/constants'
|
||||||
|
import {niceDate} from '#/lib/strings/time'
|
||||||
|
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as TextField from '#/components/forms/TextField'
|
||||||
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
|
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||||
|
|
||||||
|
type EmbedDialogProps = {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
postAuthor: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
postCid: string
|
||||||
|
postUri: string
|
||||||
|
record: AppBskyFeedPost.Record
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let EmbedDialog = ({control, ...rest}: EmbedDialogProps): React.ReactNode => {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<EmbedDialogInner {...rest} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EmbedDialog = memo(EmbedDialog)
|
||||||
|
export {EmbedDialog}
|
||||||
|
|
||||||
|
function EmbedDialogInner({
|
||||||
|
postAuthor,
|
||||||
|
postCid,
|
||||||
|
postUri,
|
||||||
|
record,
|
||||||
|
timestamp,
|
||||||
|
}: Omit<EmbedDialogProps, 'control'>) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
const ref = useRef<TextInput>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
// reset copied state after 2 seconds
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (copied) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setCopied(false)
|
||||||
|
}, 2000)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [copied])
|
||||||
|
|
||||||
|
const snippet = React.useMemo(() => {
|
||||||
|
const lang = record.langs && record.langs.length > 0 ? record.langs[0] : ''
|
||||||
|
const profileHref = toShareUrl(['/profile', postAuthor.did].join('/'))
|
||||||
|
const urip = new AtUri(postUri)
|
||||||
|
const href = toShareUrl(
|
||||||
|
['/profile', postAuthor.did, 'post', urip.rkey].join('/'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||||
|
// DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM!
|
||||||
|
// Also, keep this code synced with the bskyembed code in landing.tsx.
|
||||||
|
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||||
|
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
|
||||||
|
postUri,
|
||||||
|
)}" data-bluesky-cid="${escapeHtml(postCid)}"><p lang="${escapeHtml(
|
||||||
|
lang,
|
||||||
|
)}">${escapeHtml(record.text)}${
|
||||||
|
record.embed
|
||||||
|
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
|
||||||
|
: ''
|
||||||
|
}</p>— ${escapeHtml(
|
||||||
|
postAuthor.displayName || postAuthor.handle,
|
||||||
|
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
|
||||||
|
postAuthor.handle,
|
||||||
|
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
||||||
|
niceDate(timestamp),
|
||||||
|
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
||||||
|
}, [postUri, postCid, record, timestamp, postAuthor])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
|
||||||
|
<View style={[a.gap_sm, a.pb_lg]}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold]}>
|
||||||
|
<Trans>Embed post</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}>
|
||||||
|
<Trans>
|
||||||
|
Embed this post in your website. Simply copy the following snippet
|
||||||
|
and paste it into the HTML code of your website.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.gap_sm]}>
|
||||||
|
<TextField.Root>
|
||||||
|
<TextField.Icon icon={CodeBrackets} />
|
||||||
|
<TextField.Input
|
||||||
|
label={_(msg`Embed HTML code`)}
|
||||||
|
editable={false}
|
||||||
|
selection={{start: 0, end: snippet.length}}
|
||||||
|
value={snippet}
|
||||||
|
style={{}}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Copy code`)}
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
size="medium"
|
||||||
|
onPress={() => {
|
||||||
|
ref.current?.focus()
|
||||||
|
ref.current?.setSelection(0, snippet.length)
|
||||||
|
navigator.clipboard.writeText(snippet)
|
||||||
|
setCopied(true)
|
||||||
|
}}>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<ButtonIcon icon={Check} />
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Copied!</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Copy code</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.Inner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Based on a snippet of code from React, which itself was based on the escape-html library.
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates
|
||||||
|
* Copyright (c) 2012-2013 TJ Holowaychuk
|
||||||
|
* Copyright (c) 2015 Andreas Lubbe
|
||||||
|
* Copyright (c) 2015 Tiancheng "Timothy" Gu
|
||||||
|
* Licensed as MIT.
|
||||||
|
*/
|
||||||
|
const matchHtmlRegExp = /["'&<>]/
|
||||||
|
function escapeHtml(string: string) {
|
||||||
|
const str = String(string)
|
||||||
|
const match = matchHtmlRegExp.exec(str)
|
||||||
|
if (!match) {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
let escape
|
||||||
|
let html = ''
|
||||||
|
let index
|
||||||
|
let lastIndex = 0
|
||||||
|
for (index = match.index; index < str.length; index++) {
|
||||||
|
switch (str.charCodeAt(index)) {
|
||||||
|
case 34: // "
|
||||||
|
escape = '"'
|
||||||
|
break
|
||||||
|
case 38: // &
|
||||||
|
escape = '&'
|
||||||
|
break
|
||||||
|
case 39: // '
|
||||||
|
escape = '''
|
||||||
|
break
|
||||||
|
case 60: // <
|
||||||
|
escape = '<'
|
||||||
|
break
|
||||||
|
case 62: // >
|
||||||
|
escape = '>'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lastIndex !== index) {
|
||||||
|
html += str.slice(lastIndex, index)
|
||||||
|
}
|
||||||
|
lastIndex = index + 1
|
||||||
|
html += escape
|
||||||
|
}
|
||||||
|
return lastIndex !== index ? html + str.slice(lastIndex, index) : html
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const CodeBrackets_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M14.242 3.03a1 1 0 0 1 .728 1.213l-4 16a1 1 0 1 1-1.94-.485l4-16a1 1 0 0 1 1.213-.728ZM6.707 7.293a1 1 0 0 1 0 1.414L3.414 12l3.293 3.293a1 1 0 1 1-1.414 1.414l-4-4a1 1 0 0 1 0-1.414l4-4a1 1 0 0 1 1.414 0Zm10.586 0a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1 0 1.414l-4 4a1 1 0 1 1-1.414-1.414L20.586 12l-3.293-3.293a1 1 0 0 1 0-1.414Z',
|
||||||
|
})
|
|
@ -7,6 +7,8 @@ export const BSKY_SERVICE = 'https://bsky.social'
|
||||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||||
const HELP_DESK_LANG = 'en-us'
|
const HELP_DESK_LANG = 'en-us'
|
||||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
||||||
|
export const EMBED_SERVICE = 'https://embed.bsky.app'
|
||||||
|
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||||
|
|
||||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||||
export function FEEDBACK_FORM_URL({
|
export function FEEDBACK_FORM_URL({
|
||||||
|
|
|
@ -28,12 +28,14 @@ import {getCurrentRoute} from 'lib/routes/helpers'
|
||||||
import {shareUrl} from 'lib/sharing'
|
import {shareUrl} from 'lib/sharing'
|
||||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
import {atoms as a, useTheme as useAlf} from '#/alf'
|
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 {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'
|
||||||
|
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
||||||
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'
|
||||||
|
@ -55,6 +57,7 @@ let PostDropdownBtn = ({
|
||||||
richText,
|
richText,
|
||||||
style,
|
style,
|
||||||
hitSlop,
|
hitSlop,
|
||||||
|
timestamp,
|
||||||
}: {
|
}: {
|
||||||
testID: string
|
testID: string
|
||||||
postAuthor: AppBskyActorDefs.ProfileViewBasic
|
postAuthor: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
@ -64,10 +67,12 @@ let PostDropdownBtn = ({
|
||||||
richText: RichTextAPI
|
richText: RichTextAPI
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
hitSlop?: PressableProps['hitSlop']
|
hitSlop?: PressableProps['hitSlop']
|
||||||
|
timestamp: string
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const {hasSession, currentAccount} = useSession()
|
const {hasSession, currentAccount} = useSession()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const alf = useAlf()
|
const alf = useAlf()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const defaultCtrlColor = theme.palette.default.postCtrl
|
const defaultCtrlColor = theme.palette.default.postCtrl
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
|
@ -83,6 +88,7 @@ let PostDropdownBtn = ({
|
||||||
const deletePromptControl = useDialogControl()
|
const deletePromptControl = useDialogControl()
|
||||||
const hidePromptControl = useDialogControl()
|
const hidePromptControl = useDialogControl()
|
||||||
const loggedOutWarningPromptControl = useDialogControl()
|
const loggedOutWarningPromptControl = useDialogControl()
|
||||||
|
const embedPostControl = 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)
|
||||||
|
@ -177,6 +183,8 @@ let PostDropdownBtn = ({
|
||||||
shareUrl(url)
|
shareUrl(url)
|
||||||
}, [href])
|
}, [href])
|
||||||
|
|
||||||
|
const canEmbed = isWeb && gtMobile && !shouldShowLoggedOutWarning
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventStopper onKeyDown={false}>
|
<EventStopper onKeyDown={false}>
|
||||||
<Menu.Root>
|
<Menu.Root>
|
||||||
|
@ -238,6 +246,16 @@ let PostDropdownBtn = ({
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Share} position="right" />
|
<Menu.ItemIcon icon={Share} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
{canEmbed && (
|
||||||
|
<Menu.Item
|
||||||
|
testID="postDropdownEmbedBtn"
|
||||||
|
label={_(msg`Embed post`)}
|
||||||
|
onPress={embedPostControl.open}>
|
||||||
|
<Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={CodeBrackets} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
|
|
||||||
{hasSession && (
|
{hasSession && (
|
||||||
|
@ -350,6 +368,17 @@ let PostDropdownBtn = ({
|
||||||
onConfirm={onSharePost}
|
onConfirm={onSharePost}
|
||||||
confirmButtonCta={_(msg`Share anyway`)}
|
confirmButtonCta={_(msg`Share anyway`)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{canEmbed && (
|
||||||
|
<EmbedDialog
|
||||||
|
control={embedPostControl}
|
||||||
|
postCid={postCid}
|
||||||
|
postUri={postUri}
|
||||||
|
record={record}
|
||||||
|
postAuthor={postAuthor}
|
||||||
|
timestamp={timestamp}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</EventStopper>
|
</EventStopper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -264,6 +264,7 @@ let PostCtrls = ({
|
||||||
richText={richText}
|
richText={richText}
|
||||||
style={styles.btnPad}
|
style={styles.btnPad}
|
||||||
hitSlop={big ? HITSLOP_20 : HITSLOP_10}
|
hitSlop={big ? HITSLOP_20 : HITSLOP_10}
|
||||||
|
timestamp={post.indexedAt}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
Loading…
Reference in New Issue