about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-04-13 05:13:53 +0100
committerGitHub <noreply@github.com>2024-04-13 05:13:53 +0100
commit4c966e5d6d1cbafe7a41d58268ffcb2cee31abe8 (patch)
treec16f28032012c6123ea956b657656b9122b4ab66
parent4b3ec5573241b9c71504dfd0bd5f181cbde19a49 (diff)
downloadvoidsky-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>
-rw-r--r--assets/icons/codeBrackets_stroke2_corner0_rounded.svg1
-rw-r--r--bskyembed/src/screens/landing.tsx6
-rw-r--r--src/components/dialogs/Embed.tsx191
-rw-r--r--src/components/icons/CodeBrackets.tsx5
-rw-r--r--src/lib/constants.ts2
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx31
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx1
7 files changed, 233 insertions, 4 deletions
diff --git a/assets/icons/codeBrackets_stroke2_corner0_rounded.svg b/assets/icons/codeBrackets_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..0cc239210
--- /dev/null
+++ b/assets/icons/codeBrackets_stroke2_corner0_rounded.svg
@@ -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>
diff --git a/bskyembed/src/screens/landing.tsx b/bskyembed/src/screens/landing.tsx
index 88e84ffb6..7c8ef2810 100644
--- a/bskyembed/src/screens/landing.tsx
+++ b/bskyembed/src/screens/landing.tsx
@@ -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,
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>&mdash; ${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 = '&quot;'
+        break
+      case 38: // &
+        escape = '&amp;'
+        break
+      case 39: // '
+        escape = '&#x27;'
+        break
+      case 60: // <
+        escape = '&lt;'
+        break
+      case 62: // >
+        escape = '&gt;'
+        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>