A Vue app that displays bluesky skeets in realtime as they are created.
1<template>
2 <main>
3 <div id="search">
4 <div class="inputGroup">
5 <label for="keyword">Keyword(s)</label>
6 <input
7 type="text"
8 id="keyword"
9 name="keyword"
10 pattern="^(?:[^,]+)(?:,\s*[^,]+)*$"
11 placeholder="keyword(s) (comma-seperated)"
12 v-model="keywordsString"
13 />
14 </div>
15 <div class="inputGroup">
16 <label for="user">User(s)</label>
17 <input
18 type="text"
19 id="user"
20 name="user"
21 pattern="^(?:(?:[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\.)+[A-Za-z]{2,})(?:,\s*(?:(?:[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\.)+[A-Za-z]{2,}))*$"
22 placeholder="handle(s) (comma-seperated)"
23 v-model="usersString"
24 />
25 </div>
26 <div class="inputGroup">
27 <label for="keepNumber">Show last skeets</label>
28 <input type="number" id="keepNumber" name="keepNumber" v-model="keepNumber" />
29 </div>
30 <div class="buttonGroup">
31 <button @click="onSubmit">{{ submitWord }}</button>
32 <button @click="onStop" v-if="jetstream">Stop Stream</button>
33 </div>
34 </div>
35 <div id="view">
36 <SkeetView v-for="(skeet, index) in showSkeets" :key="index" :skeet />
37 </div>
38 </main>
39</template>
40
41<script setup lang="ts">
42import { websocketToFeedEntry } from '@/utils/feed'
43import { computed, onBeforeMount, ref, type ComputedRef, type Ref } from 'vue'
44import SkeetView from './SkeetView.vue'
45import type { Post } from '@/types/post'
46
47const keywordsString: Ref<string | undefined> = ref()
48const usersString: Ref<string | undefined> = ref()
49const keepNumber: Ref<number> = ref(25)
50const skeets: Ref<Post[]> = ref([])
51const showSkeets: ComputedRef<Post[]> = computed(() => {
52 return skeets.value.slice(0, keep.value)
53})
54
55const keep: Ref<number> = ref(25)
56const keywords: Ref<string[] | undefined> = ref()
57
58const users: ComputedRef<string[] | undefined> = computed(() => {
59 return usersString.value?.split(',')
60})
61
62const userDids: Ref<{ did: string; handle: string }[]> = ref([])
63
64const submitWord = ref('Start Stream')
65
66const onSubmit = () => {
67 keywords.value = keywordsString.value?.split(',')
68 keep.value = keepNumber.value
69 if (jetstreamRunning.value) {
70 updateWebSocket()
71 } else {
72 submitWord.value = 'Update'
73 connectWebSocket()
74 }
75}
76
77const skeetContainsKeywords = (skeet: Post) => {
78 if (!keywords.value) {
79 return true
80 }
81
82 return keywords.value.some((keyword) => skeet.text.includes(keyword))
83}
84
85const skeetIsFromAuthor = (skeet: Post) => {
86 if (!users.value) {
87 return true
88 }
89
90 return userDids.value.find((userDid) => {
91 return userDid.did === skeet.authorDid
92 })
93}
94
95const onStop = () => {
96 jetstream.value?.close()
97 jetstream.value = null
98 submitWord.value = 'Start Stream'
99}
100
101const jetstream: Ref<WebSocket | null> = ref(null)
102
103const connectWebSocket = () => {
104 jetstream.value = new WebSocket(
105 'wss://api.graze.social/app/api/v1/turbostream/turbostream?wantedCollections=app.bsky.feed.post&requireHello=true',
106 )
107
108 jetstream.value.onopen = () => {
109 console.log('WebSocket connected')
110 updateWebSocket(true)
111 }
112
113 jetstream.value.onmessage = (event) => {
114 const skeet = websocketToFeedEntry(event.data)
115 if (skeet && skeetContainsKeywords(skeet) && skeetIsFromAuthor(skeet)) {
116 skeets.value.unshift(skeet)
117 }
118 }
119
120 jetstream.value.onerror = (error) => {
121 console.error('WebSocket error:', error)
122 }
123
124 jetstream.value.onclose = () => {
125 console.log('WebSocket disconnected')
126 }
127}
128
129const getDids = async (users: string[]): Promise<{ did: string; handle: string }[]> => {
130 const results = await Promise.allSettled(
131 users.map(async (user) => {
132 const trimUser = user.trim()
133 try {
134 const response = await fetch(
135 `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${trimUser}`,
136 )
137 if (!response.ok) {
138 throw new Error(`Fehler beim Abrufen von ${trimUser}: ${response.status}`)
139 }
140 const data = await response.json()
141 return { did: data.did, handle: trimUser }
142 } catch (error) {
143 console.error(`Fehler für ${trimUser}:`, error)
144 return null // Fehlerhafte Anfragen geben `null` zurück
145 }
146 }),
147 )
148
149 // Nur erfolgreiche `did`-Werte zurückgeben
150 return results
151 .filter((result) => result.status === 'fulfilled' && result.value !== null)
152 .map((result) => (result as PromiseFulfilledResult<{ did: string; handle: string }>).value)
153}
154
155const updateWebSocket = async (silent = false) => {
156 if (!silent) {
157 console.info('update user(s) and/or keyword(s)')
158 }
159
160 userDids.value = []
161 if (users.value && users.value[0].length > 1) {
162 userDids.value = await getDids(users.value)
163 }
164 jetstream.value?.send(
165 JSON.stringify({
166 type: 'options_update',
167 payload: {
168 wantedCollections: ['app.bsky.feed.post'],
169 wantedDids: userDids.value.map((userDid) => userDid.did),
170 },
171 }),
172 )
173}
174
175const jetstreamRunning = computed(
176 () => jetstream.value && jetstream.value.readyState === WebSocket.OPEN,
177)
178
179onBeforeMount(() => {
180 jetstream.value?.close()
181})
182</script>
183
184<style lang="scss">
185main {
186 height: 100%;
187}
188#search {
189 position: sticky;
190 top: 0;
191 background: var(--color-background);
192 padding-bottom: 1rem;
193}
194
195.inputGroup {
196 display: grid;
197 grid-template-columns: auto 1fr;
198 gap: 1rem;
199 padding-bottom: 0.5rem;
200 max-width: 600px;
201}
202
203.buttonGroup {
204 display: flex;
205 gap: 1rem;
206 padding: 0.5rem 0;
207}
208
209@media screen and (max-width: 371px) {
210 .inputGroup {
211 grid-template-rows: auto auto;
212 grid-template-columns: 1fr;
213 gap: 0;
214 }
215}
216</style>