diff options
author | Samuel Newman <mozzius@protonmail.com> | 2024-04-13 05:13:53 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-04-13 05:13:53 +0100 |
commit | 4c966e5d6d1cbafe7a41d58268ffcb2cee31abe8 (patch) | |
tree | c16f28032012c6123ea956b657656b9122b4ab66 /src | |
parent | 4b3ec5573241b9c71504dfd0bd5f181cbde19a49 (diff) | |
download | voidsky-4c966e5d6d1cbafe7a41d58268ffcb2cee31abe8.tar.zst |
[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>
Diffstat (limited to 'src')
-rw-r--r-- | src/components/dialogs/Embed.tsx | 191 | ||||
-rw-r--r-- | src/components/icons/CodeBrackets.tsx | 5 | ||||
-rw-r--r-- | src/lib/constants.ts | 2 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 31 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/PostCtrls.tsx | 1 |
5 files changed, 229 insertions, 1 deletions
diff --git a/src/components/dialogs/Embed.tsx b/src/components/dialogs/Embed.tsx new file mode 100644 index 000000000..0478dbae8 --- /dev/null +++ b/src/components/dialogs/Embed.tsx @@ -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 +} diff --git a/src/components/icons/CodeBrackets.tsx b/src/components/icons/CodeBrackets.tsx new file mode 100644 index 000000000..59d5fca90 --- /dev/null +++ b/src/components/icons/CodeBrackets.tsx @@ -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', +}) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 401c39362..bb49387c4 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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({ diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 04dfa203a..31032396f 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -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> ) } diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index cd4a36373..cb50ee6dc 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -264,6 +264,7 @@ let PostCtrls = ({ richText={richText} style={styles.btnPad} hitSlop={big ? HITSLOP_20 : HITSLOP_10} + timestamp={post.indexedAt} /> </View> </View> |