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