返回 slidev
BlueSky.vue
根目录 / packages / client / builtin / BlueSky.vue
1 <!--
2 A simple wrapper for embedded Bluesky posts
3
4 Usage:
5
6 <BlueSky uri="at://did:plc:432mbsu2xucyvxl6sluohidu/app.bsky.feed.post/3lgwg2vy44s2q" />
7 <BlueSky uri="https://bsky.app/profile/sli.dev/post/3lgwg2vy44s2q" />
8 -->
9
10 <script setup lang="ts">
11 import { nextTick, onMounted, ref } from 'vue'
12 import { isDark } from '../logic/dark'
13
14 const props = defineProps<{
15 uri: string
16 scale?: string | number
17 }>()
18
19 const RE_BLUESKY_POST_URL = /^\/profile\/([^/]+)\/post\/([^/?#]+)$/
20
21 const container = ref<HTMLElement | null>(null)
22 const loaded = ref(false)
23 const postNotFound = ref(false)
24 const resolvedUri = ref('')
25 const resolvedPostUrl = ref('')
26
27 const RESOLVE_HANDLE_URL = 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle'
28 const GET_POST_THREAD_URL = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread'
29
30 interface BlueskyThreadResponse {
31 thread?: {
32 post?: {
33 uri?: string
34 }
35 }
36 }
37
38 function scan(retries = 10) {
39 // @ts-expect-error global
40 if (!window.bluesky?.scan) {
41 if (retries <= 0)
42 console.error('Failed to load Bluesky embed widget after 10 retries.')
43 if (retries <= 0)
44 return
45 setTimeout(scan, 1000, retries - 1)
46 return
47 }
48 // @ts-expect-error global
49 window.bluesky.scan(container.value || undefined)
50 }
51
52 function extractPostInfoFromUrl(input: string) {
53 try {
54 const url = new URL(input)
55 const match = url.pathname.match(RE_BLUESKY_POST_URL)
56 if (!match)
57 return
58 return {
59 handle: decodeURIComponent(match[1]),
60 postId: decodeURIComponent(match[2]),
61 postUrl: url.toString(),
62 }
63 }
64 catch {}
65 }
66
67 async function resolvePostUri(input: string) {
68 try {
69 if (input.startsWith('at://')) {
70 resolvedUri.value = input
71 resolvedPostUrl.value = ''
72 return true
73 }
74
75 const info = extractPostInfoFromUrl(input)
76 if (!info)
77 return false
78
79 const handleResponse = await fetch(`${RESOLVE_HANDLE_URL}?handle=${encodeURIComponent(info.handle)}`)
80 if (!handleResponse.ok)
81 return false
82 const handleData = await handleResponse.json() as { did?: string }
83 if (!handleData.did)
84 return false
85
86 const uri = `at://${handleData.did}/app.bsky.feed.post/${info.postId}`
87 const threadResponse = await fetch(
88 `${GET_POST_THREAD_URL}?uri=${encodeURIComponent(uri)}&depth=0&parentHeight=0`,
89 )
90 if (!threadResponse.ok)
91 return false
92
93 const threadData = await threadResponse.json() as BlueskyThreadResponse
94 const canonicalUri = threadData.thread?.post?.uri
95 if (!canonicalUri)
96 return false
97
98 resolvedUri.value = canonicalUri
99 resolvedPostUrl.value = info.postUrl
100 return true
101 }
102 catch {
103 return false
104 }
105 }
106
107 async function create(retries = 10) {
108 loaded.value = false
109 postNotFound.value = false
110
111 if (!resolvedUri.value) {
112 const ok = await resolvePostUri(props.uri)
113 if (!ok) {
114 postNotFound.value = true
115 loaded.value = true
116 return
117 }
118 }
119
120 await nextTick()
121
122 // @ts-expect-error global
123 if (!window.bluesky?.scan) {
124 if (retries <= 0) {
125 console.error('Failed to load Bluesky embed widget after 10 retries.')
126 postNotFound.value = true
127 loaded.value = true
128 return
129 }
130 setTimeout(create, 1000, retries - 1)
131 return
132 }
133
134 scan()
135 loaded.value = true
136 }
137
138 onMounted(async () => {
139 await create()
140 })
141 </script>
142
143 <template>
144 <Transform :scale="scale || 1">
145 <div ref="container" class="slidev-bluesky">
146 <blockquote
147 v-if="resolvedUri"
148 class="bluesky-embed"
149 :data-bluesky-uri="resolvedUri"
150 :data-bluesky-embed-color-mode="isDark ? 'dark' : 'light'"
151 />
152 <div v-if="!loaded || postNotFound" class="h-30 w-30 my-10px bg-gray-400 bg-opacity-10 rounded-lg flex opacity-50">
153 <div class="m-auto animate-pulse text-4xl">
154 <div class="i-simple-icons:bluesky" />
155 <span v-if="postNotFound">Could not load Bluesky post</span>
156 </div>
157 </div>
158 </div>
159 </Transform>
160 </template>
161
162 <style>
163 .slidev-bluesky .bluesky-embed iframe {
164 border-radius: 12px;
165 overflow: hidden;
166 }
167 </style>
168
168 lines Plain Text