Bluesky app fork with some witchin' additions 馃挮
at main 345 lines 11 kB view raw
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>&mdash; ${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 = '&quot;' 322 break 323 case 38: // & 324 escape = '&amp;' 325 break 326 case 39: // ' 327 escape = '&#x27;' 328 break 329 case 60: // < 330 escape = '&lt;' 331 break 332 case 62: // > 333 escape = '&gt;' 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}