A Vue app that displays bluesky skeets in realtime as they are created.
at main 216 lines 5.7 kB view raw
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>