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 {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>— ${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 = '"'
241 break
242 case 38: // &
243 escape = '&'
244 break
245 case 39: // '
246 escape = '''
247 break
248 case 60: // <
249 escape = '<'
250 break
251 case 62: // >
252 escape = '>'
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}