forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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>— ${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 = '"'
253 break
254 case 38: // &
255 escape = '&'
256 break
257 case 39: // '
258 escape = '''
259 break
260 case 60: // <
261 escape = '<'
262 break
263 case 62: // >
264 escape = '>'
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}