[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 ''
|
||||
}
|
||||
|
||||
const lang = record.langs && record.langs.length > 0 ? record.langs[0] : ''
|
||||
const profileHref = toShareUrl(
|
||||
['/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('/'),
|
||||
)
|
||||
|
||||
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
|
||||
// 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
|
||||
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
|
||||
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
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
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`
|
||||
export function FEEDBACK_FORM_URL({
|
||||
|
|
|
@ -28,12 +28,14 @@ import {getCurrentRoute} from 'lib/routes/helpers'
|
|||
import {shareUrl} from 'lib/sharing'
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
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 {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {EmbedDialog} from '#/components/dialogs/Embed'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
|
@ -55,6 +57,7 @@ let PostDropdownBtn = ({
|
|||
richText,
|
||||
style,
|
||||
hitSlop,
|
||||
timestamp,
|
||||
}: {
|
||||
testID: string
|
||||
postAuthor: AppBskyActorDefs.ProfileViewBasic
|
||||
|
@ -64,10 +67,12 @@ let PostDropdownBtn = ({
|
|||
richText: RichTextAPI
|
||||
style?: StyleProp<ViewStyle>
|
||||
hitSlop?: PressableProps['hitSlop']
|
||||
timestamp: string
|
||||
}): React.ReactNode => {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const theme = useTheme()
|
||||
const alf = useAlf()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const defaultCtrlColor = theme.palette.default.postCtrl
|
||||
const langPrefs = useLanguagePrefs()
|
||||
|
@ -83,6 +88,7 @@ let PostDropdownBtn = ({
|
|||
const deletePromptControl = useDialogControl()
|
||||
const hidePromptControl = useDialogControl()
|
||||
const loggedOutWarningPromptControl = useDialogControl()
|
||||
const embedPostControl = useDialogControl()
|
||||
|
||||
const rootUri = record.reply?.root?.uri || postUri
|
||||
const isThreadMuted = mutedThreads.includes(rootUri)
|
||||
|
@ -177,6 +183,8 @@ let PostDropdownBtn = ({
|
|||
shareUrl(url)
|
||||
}, [href])
|
||||
|
||||
const canEmbed = isWeb && gtMobile && !shouldShowLoggedOutWarning
|
||||
|
||||
return (
|
||||
<EventStopper onKeyDown={false}>
|
||||
<Menu.Root>
|
||||
|
@ -238,6 +246,16 @@ let PostDropdownBtn = ({
|
|||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Share} position="right" />
|
||||
</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>
|
||||
|
||||
{hasSession && (
|
||||
|
@ -350,6 +368,17 @@ let PostDropdownBtn = ({
|
|||
onConfirm={onSharePost}
|
||||
confirmButtonCta={_(msg`Share anyway`)}
|
||||
/>
|
||||
|
||||
{canEmbed && (
|
||||
<EmbedDialog
|
||||
control={embedPostControl}
|
||||
postCid={postCid}
|
||||
postUri={postUri}
|
||||
record={record}
|
||||
postAuthor={postAuthor}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
)}
|
||||
</EventStopper>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -264,6 +264,7 @@ let PostCtrls = ({
|
|||
richText={richText}
|
||||
style={styles.btnPad}
|
||||
hitSlop={big ? HITSLOP_20 : HITSLOP_10}
|
||||
timestamp={post.indexedAt}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
|
Loading…
Reference in New Issue