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