forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import '../index.css'
2
3import {AppBskyFeedDefs, AppBskyFeedPost, AtpAgent, AtUri} from '@atproto/api'
4import {h, render} from 'preact'
5import {useEffect, useMemo, useRef, useState} from 'preact/hooks'
6
7import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg'
8import logo from '../../assets/logo.svg'
9import {
10 assertColorModeValues,
11 ColorModeValues,
12 initSystemColorMode,
13} from '../color-mode'
14import {Container} from '../components/container'
15import {Link} from '../components/link'
16import {Post} from '../components/post'
17import * as bsky from '../types/bsky'
18import {niceDate} from '../util/nice-date'
19
20const DEFAULT_POST =
21 'https://bsky.app/profile/did:plc:vjug55kidv6sye7ykr5faxxn/post/3jzn6g7ixgq2y'
22const DEFAULT_URI =
23 'at://did:plc:vjug55kidv6sye7ykr5faxxn/app.bsky.feed.post/3jzn6g7ixgq2y'
24
25export const EMBED_SERVICE = 'https://embed.bsky.app'
26export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
27
28const root = document.getElementById('app')
29if (!root) throw new Error('No root element')
30
31initSystemColorMode()
32
33const agent = new AtpAgent({
34 service: 'https://public.api.bsky.app',
35})
36
37render(<LandingPage />, root)
38
39function LandingPage() {
40 const [uri, setUri] = useState('')
41 const [colorMode, setColorMode] = useState<ColorModeValues>('system')
42 const [error, setError] = useState<string | null>(null)
43 const [loading, setLoading] = useState(false)
44 const [thread, setThread] = useState<AppBskyFeedDefs.ThreadViewPost | null>(
45 null,
46 )
47
48 useEffect(() => {
49 void (async () => {
50 setError(null)
51 setThread(null)
52 setLoading(true)
53 try {
54 let atUri = DEFAULT_URI
55
56 if (uri) {
57 if (uri.startsWith('at://')) {
58 atUri = uri
59 } else {
60 try {
61 const urlp = new URL(uri)
62 if (!urlp.hostname.endsWith('bsky.app')) {
63 throw new Error('Invalid hostname')
64 }
65 const split = urlp.pathname.slice(1).split('/')
66 if (split.length < 4) {
67 throw new Error('Invalid pathname')
68 }
69 const [profile, didOrHandle, type, rkey] = split
70 if (profile !== 'profile' || type !== 'post') {
71 throw new Error('Invalid profile or type')
72 }
73
74 let did = didOrHandle
75 if (!didOrHandle.startsWith('did:')) {
76 const resolution = await agent.resolveHandle({
77 handle: didOrHandle,
78 })
79 if (!resolution.data.did) {
80 throw new Error('No DID found')
81 }
82 did = resolution.data.did
83 }
84
85 atUri = `at://${did}/app.bsky.feed.post/${rkey}`
86 } catch (err) {
87 console.log(err)
88 throw new Error('Invalid Bluesky URL')
89 }
90 }
91 }
92
93 const {data} = await agent.getPostThread({
94 uri: atUri,
95 depth: 0,
96 parentHeight: 0,
97 })
98
99 if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
100 throw new Error('Post not found')
101 }
102 const pwiOptOut = !!data.thread.post.author.labels?.find(
103 label => label.val === '!no-unauthenticated',
104 )
105 if (pwiOptOut) {
106 throw new Error(
107 'The author of this post has requested their posts not be displayed on external sites.',
108 )
109 }
110 setThread(data.thread)
111 } catch (err) {
112 console.error(err)
113 setError(err instanceof Error ? err.message : 'Invalid Bluesky URL')
114 } finally {
115 setLoading(false)
116 }
117 })()
118 }, [uri])
119
120 return (
121 <main className="w-full min-h-screen flex flex-col items-center gap-8 py-14 px-4 md:pt-32 dark:bg-dimmedBgDarken dark:text-slate-200">
122 <Link
123 href="https://bsky.social/about"
124 className="transition-transform hover:scale-110">
125 <img src={logo} className="h-10" />
126 </Link>
127
128 <h1 className="text-4xl font-bold text-center">Embed a Bluesky Post</h1>
129
130 <div className="flex flex-col w-full max-w-[600px] gap-6">
131 <input
132 type="text"
133 value={uri}
134 onInput={e => setUri(e.currentTarget.value)}
135 className="border rounded-lg py-3 px-4 dark:bg-dimmedBg dark:border-slate-500"
136 placeholder={DEFAULT_POST}
137 />
138
139 <div className="flex flex-col gap-1.5">
140 <label className="text-sm font-medium" for="colorModeSelect">
141 Theme
142 </label>
143 <select
144 value={colorMode}
145 onChange={e => {
146 const value = e.currentTarget.value
147 if (assertColorModeValues(value)) {
148 setColorMode(value)
149 }
150 }}
151 id="colorModeSelect"
152 className="appearance-none bg-white border w-full rounded-lg text-sm px-3 py-2 dark:bg-dimmedBg dark:border-slate-500">
153 <option value="system">System</option>
154 <option value="light">Light</option>
155 <option value="dark">Dark</option>
156 </select>
157 </div>
158 </div>
159
160 <img src={arrowBottom} className="w-6 dark:invert" />
161
162 {loading ? (
163 <div className={`${colorMode} w-full max-w-[600px]`}>
164 <Skeleton />
165 </div>
166 ) : (
167 <div className="w-full max-w-[600px] gap-8 flex flex-col">
168 {!error && thread && uri && (
169 <Snippet thread={thread} colorMode={colorMode} />
170 )}
171 <div className={colorMode}>
172 {!error && thread && <Post thread={thread} key={thread.post.uri} />}
173 </div>
174 {error && (
175 <div className="w-full border border-red-500 bg-red-500/10 px-4 py-3 rounded-lg">
176 <p className="text-red-500 text-center">{error}</p>
177 </div>
178 )}
179 </div>
180 )}
181 </main>
182 )
183}
184
185function Skeleton() {
186 return (
187 <Container>
188 <div className="flex-1 flex-col flex gap-2 pb-8">
189 <div className="flex gap-2.5 items-center">
190 <div className="w-10 h-10 overflow-hidden rounded-full bg-neutral-100 dark:bg-slate-700 shrink-0 animate-pulse" />
191 <div className="flex-1">
192 <div className="bg-neutral-100 dark:bg-slate-700 animate-pulse w-64 h-4 rounded" />
193 <div className="bg-neutral-100 dark:bg-slate-700 animate-pulse w-32 h-3 mt-1 rounded" />
194 </div>
195 </div>
196 <div className="w-full h-4 mt-2 bg-neutral-100 dark:bg-slate-700 rounded animate-pulse" />
197 <div className="w-5/6 h-4 bg-neutral-100 dark:bg-slate-700 rounded animate-pulse" />
198 <div className="w-3/4 h-4 bg-neutral-100 dark:bg-slate-700 rounded animate-pulse" />
199 </div>
200 </Container>
201 )
202}
203
204function Snippet({
205 thread,
206 colorMode,
207}: {
208 thread: AppBskyFeedDefs.ThreadViewPost
209 colorMode: ColorModeValues
210}) {
211 const ref = useRef<HTMLInputElement>(null)
212 const [copied, setCopied] = useState(false)
213
214 // reset copied state after 2 seconds
215 useEffect(() => {
216 if (copied) {
217 const timeout = setTimeout(() => {
218 setCopied(false)
219 }, 2000)
220 return () => clearTimeout(timeout)
221 }
222 }, [copied])
223
224 const snippet = useMemo(() => {
225 const record = thread.post.record
226
227 if (
228 !bsky.dangerousIsType<AppBskyFeedPost.Record>(
229 record,
230 AppBskyFeedPost.isRecord,
231 )
232 ) {
233 return ''
234 }
235
236 const lang = record.langs && record.langs.length > 0 ? record.langs[0] : ''
237 const profileHref = toShareUrl(
238 ['/profile', thread.post.author.did].join('/'),
239 )
240 const urip = new AtUri(thread.post.uri)
241 const href = toShareUrl(
242 ['/profile', thread.post.author.did, 'post', urip.rkey].join('/'),
243 )
244
245 // 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
246 // DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM!
247 // Also, keep this code synced with the app code in Embed.tsx.
248 // 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
249 return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
250 thread.post.uri,
251 )}" data-bluesky-cid="${escapeHtml(
252 thread.post.cid,
253 )}" data-bluesky-embed-color-mode="${escapeHtml(
254 colorMode,
255 )}"><p lang="${escapeHtml(lang)}">${escapeHtml(record.text)}${
256 record.embed
257 ? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
258 : ''
259 }</p>— ${escapeHtml(
260 thread.post.author.displayName || thread.post.author.handle,
261 )} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
262 thread.post.author.handle,
263 )}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
264 niceDate(thread.post.indexedAt),
265 )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
266 }, [thread, colorMode])
267
268 return (
269 <div className="flex gap-2 w-full">
270 <input
271 ref={ref}
272 type="text"
273 value={snippet}
274 className="border rounded-lg py-3 w-full px-4 dark:bg-dimmedBg dark:border-slate-500"
275 readOnly
276 autoFocus
277 onFocus={() => {
278 ref.current?.select()
279 }}
280 />
281 <button
282 className="rounded-lg bg-brand text-white py-3 px-4 whitespace-nowrap min-w-28"
283 onClick={() => {
284 ref.current?.focus()
285 ref.current?.select()
286 void navigator.clipboard.writeText(snippet)
287 setCopied(true)
288 }}>
289 {copied ? 'Copied!' : 'Copy code'}
290 </button>
291 </div>
292 )
293}
294
295function toShareUrl(path: string) {
296 return `https://bsky.app${path}?ref_src=embed`
297}
298
299/**
300 * Based on a snippet of code from React, which itself was based on the escape-html library.
301 * Copyright (c) Meta Platforms, Inc. and affiliates
302 * Copyright (c) 2012-2013 TJ Holowaychuk
303 * Copyright (c) 2015 Andreas Lubbe
304 * Copyright (c) 2015 Tiancheng "Timothy" Gu
305 * Licensed as MIT.
306 */
307const matchHtmlRegExp = /["'&<>]/
308function escapeHtml(string: string) {
309 const str = String(string)
310 const match = matchHtmlRegExp.exec(str)
311 if (!match) {
312 return str
313 }
314 let escape
315 let html = ''
316 let index
317 let lastIndex = 0
318 for (index = match.index; index < str.length; index++) {
319 switch (str.charCodeAt(index)) {
320 case 34: // "
321 escape = '"'
322 break
323 case 38: // &
324 escape = '&'
325 break
326 case 39: // '
327 escape = '''
328 break
329 case 60: // <
330 escape = '<'
331 break
332 case 62: // >
333 escape = '>'
334 break
335 default:
336 continue
337 }
338 if (lastIndex !== index) {
339 html += str.slice(lastIndex, index)
340 }
341 lastIndex = index + 1
342 html += escape
343 }
344 return lastIndex !== index ? html + str.slice(lastIndex, index) : html
345}