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