Bluesky app fork with some witchin' additions 馃挮
at post-text-option 276 lines 9.5 kB view raw
1import {memo, useEffect, useMemo, useState} from 'react' 2import {View} from 'react-native' 3import {type AppBskyActorDefs, type AppBskyFeedPost, AtUri} from '@atproto/api' 4import {msg, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6 7import {EMBED_SCRIPT} from '#/lib/constants' 8import {niceDate} from '#/lib/strings/time' 9import {getTerminology} from '#/lib/strings/terminology' 10import {useTerminologyPreference} from '#/state/preferences' 11import {toShareUrl} from '#/lib/strings/url-helpers' 12import {atoms as a, useTheme} from '#/alf' 13import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14import * as Dialog from '#/components/Dialog' 15import * as SegmentedControl from '#/components/forms/SegmentedControl' 16import * as TextField from '#/components/forms/TextField' 17import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 18import { 19 ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottomIcon, 20 ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon, 21} from '#/components/icons/Chevron' 22import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' 23import {Text} from '#/components/Typography' 24 25export type ColorModeValues = 'system' | 'light' | 'dark' 26 27type EmbedDialogProps = { 28 control: Dialog.DialogControlProps 29 postAuthor: AppBskyActorDefs.ProfileViewBasic 30 postCid: string 31 postUri: string 32 record: AppBskyFeedPost.Record 33 timestamp: string 34} 35 36let EmbedDialog = ({control, ...rest}: EmbedDialogProps): React.ReactNode => { 37 return ( 38 <Dialog.Outer control={control}> 39 <Dialog.Handle /> 40 <EmbedDialogInner {...rest} /> 41 </Dialog.Outer> 42 ) 43} 44EmbedDialog = memo(EmbedDialog) 45export {EmbedDialog} 46 47function EmbedDialogInner({ 48 postAuthor, 49 postCid, 50 postUri, 51 record, 52 timestamp, 53}: Omit<EmbedDialogProps, 'control'>) { 54 const t = useTheme() 55 const {_, i18n} = useLingui() 56 const terminologyPreference = useTerminologyPreference() 57 const [copied, setCopied] = useState(false) 58 const [showCustomisation, setShowCustomisation] = useState(false) 59 const [colorMode, setColorMode] = useState<ColorModeValues>('system') 60 61 // reset copied state after 2 seconds 62 useEffect(() => { 63 if (copied) { 64 const timeout = setTimeout(() => { 65 setCopied(false) 66 }, 2000) 67 return () => clearTimeout(timeout) 68 } 69 }, [copied]) 70 71 const snippet = useMemo(() => { 72 function toEmbedUrl(href: string) { 73 return toShareUrl(href) + '?ref_src=embed' 74 } 75 76 const lang = record.langs && record.langs.length > 0 ? record.langs[0] : '' 77 const profileHref = toEmbedUrl(['/profile', postAuthor.did].join('/')) 78 const urip = new AtUri(postUri) 79 const href = toEmbedUrl( 80 ['/profile', postAuthor.did, 'post', urip.rkey].join('/'), 81 ) 82 83 // 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 84 // DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM! 85 // Also, keep this code synced with the bskyembed code in landing.tsx. 86 // 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 87 return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml( 88 postUri, 89 )}" data-bluesky-cid="${escapeHtml( 90 postCid, 91 )}" data-bluesky-embed-color-mode="${escapeHtml( 92 colorMode, 93 )}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${ 94 record.embed 95 ? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>` 96 : '' 97 }</p>&mdash; ${escapeHtml( 98 postAuthor.displayName || postAuthor.handle, 99 )} (<a href="${escapeHtml(profileHref)}">@${escapeHtml( 100 postAuthor.handle, 101 )}</a>) <a href="${escapeHtml(href)}">${escapeHtml( 102 niceDate(i18n, timestamp), 103 )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>` 104 }, [i18n, postUri, postCid, record, timestamp, postAuthor, colorMode]) 105 106 return ( 107 <Dialog.Inner label={_(getTerminology(terminologyPreference, { 108 skeet: msg`Embed skeet`, 109 post: msg`Embed post`, 110 spell: msg`Embed spell`, 111 }))} style={[{maxWidth: 500}]}> 112 <View style={[a.gap_lg]}> 113 <View style={[a.gap_sm]}> 114 <Text style={[a.text_2xl, a.font_bold]}> 115 <Trans>{_(getTerminology(terminologyPreference, { 116 skeet: msg`Embed skeet`, 117 post: msg`Embed post`, 118 spell: msg`Embed spell`, 119 }))}</Trans> 120 </Text> 121 <Text 122 style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}> 123 <Trans>{_(getTerminology(terminologyPreference, { 124 skeet: msg`Embed this skeet in your website. Simply copy the following snippet and paste it into the HTML code of your website.`, 125 post: msg`Embed this post in your website. Simply copy the following snippet and paste it into the HTML code of your website.`, 126 spell: msg`Embed this spell in your website. Simply copy the following snippet and paste it into the HTML code of your website.`, 127 }))}</Trans> 128 </Text> 129 </View> 130 <View 131 style={[ 132 a.border, 133 t.atoms.border_contrast_low, 134 a.rounded_sm, 135 a.overflow_hidden, 136 ]}> 137 <Button 138 label={ 139 showCustomisation 140 ? _(msg`Hide customization options`) 141 : _(msg`Show customization options`) 142 } 143 color="secondary" 144 variant="ghost" 145 size="small" 146 shape="default" 147 onPress={() => setShowCustomisation(c => !c)} 148 style={[ 149 a.justify_start, 150 showCustomisation && t.atoms.bg_contrast_25, 151 ]}> 152 <ButtonIcon 153 icon={showCustomisation ? ChevronBottomIcon : ChevronRightIcon} 154 /> 155 <ButtonText> 156 <Trans>Customization options</Trans> 157 </ButtonText> 158 </Button> 159 160 {showCustomisation && ( 161 <View style={[a.gap_sm, a.p_md]}> 162 <Text style={[t.atoms.text_contrast_medium, a.font_semi_bold]}> 163 <Trans>Color theme</Trans> 164 </Text> 165 <SegmentedControl.Root 166 label={_(msg`Color mode`)} 167 type="radio" 168 value={colorMode} 169 onChange={setColorMode}> 170 <SegmentedControl.Item value="system" label={_(msg`System`)}> 171 <SegmentedControl.ItemText> 172 <Trans>System</Trans> 173 </SegmentedControl.ItemText> 174 </SegmentedControl.Item> 175 <SegmentedControl.Item value="light" label={_(msg`Light`)}> 176 <SegmentedControl.ItemText> 177 <Trans>Light</Trans> 178 </SegmentedControl.ItemText> 179 </SegmentedControl.Item> 180 <SegmentedControl.Item value="dark" label={_(msg`Dark`)}> 181 <SegmentedControl.ItemText> 182 <Trans>Dark</Trans> 183 </SegmentedControl.ItemText> 184 </SegmentedControl.Item> 185 </SegmentedControl.Root> 186 </View> 187 )} 188 </View> 189 <View style={[a.flex_row, a.gap_sm]}> 190 <View style={[a.flex_1]}> 191 <TextField.Root> 192 <TextField.Icon icon={CodeBracketsIcon} /> 193 <TextField.Input 194 label={_(msg`Embed HTML code`)} 195 editable={false} 196 selection={{start: 0, end: snippet.length}} 197 value={snippet} 198 /> 199 </TextField.Root> 200 </View> 201 <Button 202 label={_(msg`Copy code`)} 203 color="primary" 204 variant="solid" 205 size="large" 206 onPress={() => { 207 navigator.clipboard.writeText(snippet) 208 setCopied(true) 209 }}> 210 {copied ? ( 211 <> 212 <ButtonIcon icon={CheckIcon} /> 213 <ButtonText> 214 <Trans>Copied!</Trans> 215 </ButtonText> 216 </> 217 ) : ( 218 <ButtonText> 219 <Trans>Copy code</Trans> 220 </ButtonText> 221 )} 222 </Button> 223 </View> 224 </View> 225 <Dialog.Close /> 226 </Dialog.Inner> 227 ) 228} 229 230/** 231 * Based on a snippet of code from React, which itself was based on the escape-html library. 232 * Copyright (c) Meta Platforms, Inc. and affiliates 233 * Copyright (c) 2012-2013 TJ Holowaychuk 234 * Copyright (c) 2015 Andreas Lubbe 235 * Copyright (c) 2015 Tiancheng "Timothy" Gu 236 * Licensed as MIT. 237 */ 238const matchHtmlRegExp = /["'&<>]/ 239function escapeHtml(string: string) { 240 const str = String(string) 241 const match = matchHtmlRegExp.exec(str) 242 if (!match) { 243 return str 244 } 245 let escape 246 let html = '' 247 let index 248 let lastIndex = 0 249 for (index = match.index; index < str.length; index++) { 250 switch (str.charCodeAt(index)) { 251 case 34: // " 252 escape = '&quot;' 253 break 254 case 38: // & 255 escape = '&amp;' 256 break 257 case 39: // ' 258 escape = '&#x27;' 259 break 260 case 60: // < 261 escape = '&lt;' 262 break 263 case 62: // > 264 escape = '&gt;' 265 break 266 default: 267 continue 268 } 269 if (lastIndex !== index) { 270 html += str.slice(lastIndex, index) 271 } 272 lastIndex = index + 1 273 html += escape 274 } 275 return lastIndex !== index ? html + str.slice(lastIndex, index) : html 276}