Bluesky app fork with some witchin' additions 💫

[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>

authored by samuel.fm

Dan Abramov and committed by
GitHub
4c966e5d 4b3ec557

+233 -4
+1
assets/icons/codeBrackets_stroke2_corner0_rounded.svg
··· 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>
+3 -3
bskyembed/src/screens/landing.tsx
··· 159 159 return '' 160 160 } 161 161 162 + const lang = record.langs && record.langs.length > 0 ? record.langs[0] : '' 162 163 const profileHref = toShareUrl( 163 164 ['/profile', thread.post.author.did].join('/'), 164 165 ) ··· 167 168 ['/profile', thread.post.author.did, 'post', urip.rkey].join('/'), 168 169 ) 169 170 170 - const lang = record.langs ? record.langs[0] : '' 171 - 172 171 // 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 173 - // DO NOT ADD ANY NEW INTERPOLATIOONS BELOW WITHOUT ESCAPING THEM! 172 + // DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM! 173 + // Also, keep this code synced with the app code in Embed.tsx. 174 174 // 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 175 175 return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml( 176 176 thread.post.uri,
+191
src/components/dialogs/Embed.tsx
··· 1 + import React, {memo, useRef, useState} from 'react' 2 + import {TextInput, View} from 'react-native' 3 + import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {EMBED_SCRIPT} from '#/lib/constants' 8 + import {niceDate} from '#/lib/strings/time' 9 + import {toShareUrl} from '#/lib/strings/url-helpers' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import * as Dialog from '#/components/Dialog' 12 + import * as TextField from '#/components/forms/TextField' 13 + import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 14 + import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' 15 + import {Text} from '#/components/Typography' 16 + import {Button, ButtonIcon, ButtonText} from '../Button' 17 + 18 + type EmbedDialogProps = { 19 + control: Dialog.DialogControlProps 20 + postAuthor: AppBskyActorDefs.ProfileViewBasic 21 + postCid: string 22 + postUri: string 23 + record: AppBskyFeedPost.Record 24 + timestamp: string 25 + } 26 + 27 + let EmbedDialog = ({control, ...rest}: EmbedDialogProps): React.ReactNode => { 28 + return ( 29 + <Dialog.Outer control={control}> 30 + <Dialog.Handle /> 31 + <EmbedDialogInner {...rest} /> 32 + </Dialog.Outer> 33 + ) 34 + } 35 + EmbedDialog = memo(EmbedDialog) 36 + export {EmbedDialog} 37 + 38 + function EmbedDialogInner({ 39 + postAuthor, 40 + postCid, 41 + postUri, 42 + record, 43 + timestamp, 44 + }: Omit<EmbedDialogProps, 'control'>) { 45 + const t = useTheme() 46 + const {_} = useLingui() 47 + const ref = useRef<TextInput>(null) 48 + const [copied, setCopied] = useState(false) 49 + 50 + // reset copied state after 2 seconds 51 + React.useEffect(() => { 52 + if (copied) { 53 + const timeout = setTimeout(() => { 54 + setCopied(false) 55 + }, 2000) 56 + return () => clearTimeout(timeout) 57 + } 58 + }, [copied]) 59 + 60 + const snippet = React.useMemo(() => { 61 + const lang = record.langs && record.langs.length > 0 ? record.langs[0] : '' 62 + const profileHref = toShareUrl(['/profile', postAuthor.did].join('/')) 63 + const urip = new AtUri(postUri) 64 + const href = toShareUrl( 65 + ['/profile', postAuthor.did, 'post', urip.rkey].join('/'), 66 + ) 67 + 68 + // 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 69 + // DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM! 70 + // Also, keep this code synced with the bskyembed code in landing.tsx. 71 + // 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 72 + return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml( 73 + postUri, 74 + )}" data-bluesky-cid="${escapeHtml(postCid)}"><p lang="${escapeHtml( 75 + lang, 76 + )}">${escapeHtml(record.text)}${ 77 + record.embed 78 + ? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>` 79 + : '' 80 + }</p>&mdash; ${escapeHtml( 81 + postAuthor.displayName || postAuthor.handle, 82 + )} (<a href="${escapeHtml(profileHref)}">@${escapeHtml( 83 + postAuthor.handle, 84 + )}</a>) <a href="${escapeHtml(href)}">${escapeHtml( 85 + niceDate(timestamp), 86 + )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>` 87 + }, [postUri, postCid, record, timestamp, postAuthor]) 88 + 89 + return ( 90 + <Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}> 91 + <View style={[a.gap_sm, a.pb_lg]}> 92 + <Text style={[a.text_2xl, a.font_bold]}> 93 + <Trans>Embed post</Trans> 94 + </Text> 95 + <Text 96 + style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}> 97 + <Trans> 98 + Embed this post in your website. Simply copy the following snippet 99 + and paste it into the HTML code of your website. 100 + </Trans> 101 + </Text> 102 + </View> 103 + 104 + <View style={[a.flex_row, a.gap_sm]}> 105 + <TextField.Root> 106 + <TextField.Icon icon={CodeBrackets} /> 107 + <TextField.Input 108 + label={_(msg`Embed HTML code`)} 109 + editable={false} 110 + selection={{start: 0, end: snippet.length}} 111 + value={snippet} 112 + style={{}} 113 + /> 114 + </TextField.Root> 115 + <Button 116 + label={_(msg`Copy code`)} 117 + color="primary" 118 + variant="solid" 119 + size="medium" 120 + onPress={() => { 121 + ref.current?.focus() 122 + ref.current?.setSelection(0, snippet.length) 123 + navigator.clipboard.writeText(snippet) 124 + setCopied(true) 125 + }}> 126 + {copied ? ( 127 + <> 128 + <ButtonIcon icon={Check} /> 129 + <ButtonText> 130 + <Trans>Copied!</Trans> 131 + </ButtonText> 132 + </> 133 + ) : ( 134 + <ButtonText> 135 + <Trans>Copy code</Trans> 136 + </ButtonText> 137 + )} 138 + </Button> 139 + </View> 140 + <Dialog.Close /> 141 + </Dialog.Inner> 142 + ) 143 + } 144 + 145 + /** 146 + * Based on a snippet of code from React, which itself was based on the escape-html library. 147 + * Copyright (c) Meta Platforms, Inc. and affiliates 148 + * Copyright (c) 2012-2013 TJ Holowaychuk 149 + * Copyright (c) 2015 Andreas Lubbe 150 + * Copyright (c) 2015 Tiancheng "Timothy" Gu 151 + * Licensed as MIT. 152 + */ 153 + const matchHtmlRegExp = /["'&<>]/ 154 + function escapeHtml(string: string) { 155 + const str = String(string) 156 + const match = matchHtmlRegExp.exec(str) 157 + if (!match) { 158 + return str 159 + } 160 + let escape 161 + let html = '' 162 + let index 163 + let lastIndex = 0 164 + for (index = match.index; index < str.length; index++) { 165 + switch (str.charCodeAt(index)) { 166 + case 34: // " 167 + escape = '&quot;' 168 + break 169 + case 38: // & 170 + escape = '&amp;' 171 + break 172 + case 39: // ' 173 + escape = '&#x27;' 174 + break 175 + case 60: // < 176 + escape = '&lt;' 177 + break 178 + case 62: // > 179 + escape = '&gt;' 180 + break 181 + default: 182 + continue 183 + } 184 + if (lastIndex !== index) { 185 + html += str.slice(lastIndex, index) 186 + } 187 + lastIndex = index + 1 188 + html += escape 189 + } 190 + return lastIndex !== index ? html + str.slice(lastIndex, index) : html 191 + }
+5
src/components/icons/CodeBrackets.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const CodeBrackets_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+2
src/lib/constants.ts
··· 7 7 export const DEFAULT_SERVICE = BSKY_SERVICE 8 8 const HELP_DESK_LANG = 'en-us' 9 9 export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}` 10 + export const EMBED_SERVICE = 'https://embed.bsky.app' 11 + export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js` 10 12 11 13 const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new` 12 14 export function FEEDBACK_FORM_URL({
+30 -1
src/view/com/util/forms/PostDropdownBtn.tsx
··· 28 28 import {shareUrl} from 'lib/sharing' 29 29 import {toShareUrl} from 'lib/strings/url-helpers' 30 30 import {useTheme} from 'lib/ThemeContext' 31 - import {atoms as a, useTheme as useAlf} from '#/alf' 31 + import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf' 32 32 import {useDialogControl} from '#/components/Dialog' 33 33 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 34 + import {EmbedDialog} from '#/components/dialogs/Embed' 34 35 import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 35 36 import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' 36 37 import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' 38 + import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets' 37 39 import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' 38 40 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 39 41 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' ··· 55 57 richText, 56 58 style, 57 59 hitSlop, 60 + timestamp, 58 61 }: { 59 62 testID: string 60 63 postAuthor: AppBskyActorDefs.ProfileViewBasic ··· 64 67 richText: RichTextAPI 65 68 style?: StyleProp<ViewStyle> 66 69 hitSlop?: PressableProps['hitSlop'] 70 + timestamp: string 67 71 }): React.ReactNode => { 68 72 const {hasSession, currentAccount} = useSession() 69 73 const theme = useTheme() 70 74 const alf = useAlf() 75 + const {gtMobile} = useBreakpoints() 71 76 const {_} = useLingui() 72 77 const defaultCtrlColor = theme.palette.default.postCtrl 73 78 const langPrefs = useLanguagePrefs() ··· 83 88 const deletePromptControl = useDialogControl() 84 89 const hidePromptControl = useDialogControl() 85 90 const loggedOutWarningPromptControl = useDialogControl() 91 + const embedPostControl = useDialogControl() 86 92 87 93 const rootUri = record.reply?.root?.uri || postUri 88 94 const isThreadMuted = mutedThreads.includes(rootUri) ··· 177 183 shareUrl(url) 178 184 }, [href]) 179 185 186 + const canEmbed = isWeb && gtMobile && !shouldShowLoggedOutWarning 187 + 180 188 return ( 181 189 <EventStopper onKeyDown={false}> 182 190 <Menu.Root> ··· 238 246 </Menu.ItemText> 239 247 <Menu.ItemIcon icon={Share} position="right" /> 240 248 </Menu.Item> 249 + 250 + {canEmbed && ( 251 + <Menu.Item 252 + testID="postDropdownEmbedBtn" 253 + label={_(msg`Embed post`)} 254 + onPress={embedPostControl.open}> 255 + <Menu.ItemText>{_(msg`Embed post`)}</Menu.ItemText> 256 + <Menu.ItemIcon icon={CodeBrackets} position="right" /> 257 + </Menu.Item> 258 + )} 241 259 </Menu.Group> 242 260 243 261 {hasSession && ( ··· 350 368 onConfirm={onSharePost} 351 369 confirmButtonCta={_(msg`Share anyway`)} 352 370 /> 371 + 372 + {canEmbed && ( 373 + <EmbedDialog 374 + control={embedPostControl} 375 + postCid={postCid} 376 + postUri={postUri} 377 + record={record} 378 + postAuthor={postAuthor} 379 + timestamp={timestamp} 380 + /> 381 + )} 353 382 </EventStopper> 354 383 ) 355 384 }
+1
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 264 264 richText={richText} 265 265 style={styles.btnPad} 266 266 hitSlop={big ? HITSLOP_20 : HITSLOP_10} 267 + timestamp={post.indexedAt} 267 268 /> 268 269 </View> 269 270 </View>