tangled
alpha
login
or
join now
eric.wien
/
bsky-realtime
0
fork
atom
A Vue app that displays bluesky skeets in realtime as they are created.
0
fork
atom
overview
issues
pulls
pipelines
adds color, simplifies some css
vinerima.tngl.sh
1 year ago
6216eeee
c47abdce
+105
-132
6 changed files
expand all
collapse all
unified
split
src
assets
base.css
main.css
components
AppHeader.vue
AppMain.vue
SkeetSearch.vue
SkeetView.vue
-78
src/assets/base.css
···
1
1
-
/* color palette from <https://github.com/vuejs/theme> */
2
2
-
:root {
3
3
-
--vt-c-white: #ffffff;
4
4
-
--vt-c-white-soft: #f8f8f8;
5
5
-
--vt-c-white-mute: #f2f2f2;
6
6
-
7
7
-
--vt-c-black: #181818;
8
8
-
--vt-c-black-soft: #222222;
9
9
-
--vt-c-black-mute: #282828;
10
10
-
11
11
-
--vt-c-indigo: #2c3e50;
12
12
-
13
13
-
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
14
14
-
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
15
15
-
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
16
16
-
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
17
17
-
18
18
-
--vt-c-text-light-1: var(--vt-c-indigo);
19
19
-
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
20
20
-
--vt-c-text-dark-1: var(--vt-c-white);
21
21
-
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
22
22
-
}
23
23
-
24
24
-
/* semantic color variables for this project */
25
25
-
:root {
26
26
-
--color-background: var(--vt-c-white);
27
27
-
--color-background-soft: var(--vt-c-white-soft);
28
28
-
--color-background-mute: var(--vt-c-white-mute);
29
29
-
30
30
-
--color-border: var(--vt-c-divider-light-2);
31
31
-
--color-border-hover: var(--vt-c-divider-light-1);
32
32
-
33
33
-
--color-heading: var(--vt-c-text-light-1);
34
34
-
--color-text: var(--vt-c-text-light-1);
35
35
-
36
36
-
--section-gap: 160px;
37
37
-
}
38
38
-
39
39
-
@media (prefers-color-scheme: dark) {
40
40
-
:root {
41
41
-
--color-background: var(--vt-c-black);
42
42
-
--color-background-soft: var(--vt-c-black-soft);
43
43
-
--color-background-mute: var(--vt-c-black-mute);
44
44
-
45
45
-
--color-border: var(--vt-c-divider-dark-2);
46
46
-
--color-border-hover: var(--vt-c-divider-dark-1);
47
47
-
48
48
-
--color-heading: var(--vt-c-text-dark-1);
49
49
-
--color-text: var(--vt-c-text-dark-2);
50
50
-
}
51
51
-
}
52
52
-
53
53
-
*,
54
54
-
*::before,
55
55
-
*::after {
56
56
-
box-sizing: border-box;
57
57
-
margin: 0;
58
58
-
font-weight: normal;
59
59
-
}
60
60
-
61
61
-
body {
62
62
-
min-height: 100dvh;
63
63
-
color: var(--color-text);
64
64
-
background: var(--color-background);
65
65
-
transition:
66
66
-
color 0.5s,
67
67
-
background-color 0.5s;
68
68
-
line-height: 1.6;
69
69
-
font-family: monospace;
70
70
-
font-size: 15px;
71
71
-
text-rendering: optimizeLegibility;
72
72
-
-webkit-font-smoothing: antialiased;
73
73
-
-moz-osx-font-smoothing: grayscale;
74
74
-
}
75
75
-
76
76
-
:focus-visible {
77
77
-
outline: none;
78
78
-
}
+62
-4
src/assets/main.css
···
1
1
-
@import './base.css';
1
1
+
:root {
2
2
+
color-scheme: light dark;
3
3
+
--color-background: light-dark(#f8f8f8, #181818);
4
4
+
--color-heading: light-dark(rgb(32, 19, 142), rgb(117, 108, 218));
5
5
+
--color-text: light-dark(black, rgba(255, 255, 255, 0.7));
6
6
+
--color-link: var(--color-heading);
7
7
+
--color-link-hover: light-dark(rgb(19, 11, 87), rgb(98, 90, 184));
8
8
+
--color-input-hover: var(--color-heading);
9
9
+
--color-input-focus-visible: var(var(--color-heading));
10
10
+
}
11
11
+
12
12
+
*,
13
13
+
*::before,
14
14
+
*::after {
15
15
+
box-sizing: border-box;
16
16
+
margin: 0;
17
17
+
font-weight: normal;
18
18
+
}
19
19
+
20
20
+
body {
21
21
+
min-height: 100dvh;
22
22
+
color: var(--color-text);
23
23
+
background: var(--color-background);
24
24
+
transition:
25
25
+
color 0.5s,
26
26
+
background-color 0.5s;
27
27
+
line-height: 1.6;
28
28
+
font-family: monospace;
29
29
+
font-size: 16px;
30
30
+
text-rendering: optimizeLegibility;
31
31
+
-webkit-font-smoothing: antialiased;
32
32
+
-moz-osx-font-smoothing: grayscale;
33
33
+
}
34
34
+
35
35
+
:focus-visible {
36
36
+
outline: none;
37
37
+
}
2
38
3
39
#app {
4
40
height: 100dvh;
···
9
45
}
10
46
11
47
a {
12
12
-
color: white;
48
48
+
color: var(--color-link);
49
49
+
50
50
+
&:hover,
51
51
+
&:visited {
52
52
+
color: var(--color-link-hover);
53
53
+
}
54
54
+
}
55
55
+
56
56
+
button,
57
57
+
input {
58
58
+
border: 2px solid var(--color-text);
59
59
+
background-color: inherit;
60
60
+
color: var(--color-text);
61
61
+
border-radius: 4px;
62
62
+
font-size: 1rem;
63
63
+
padding: 4px;
64
64
+
font-family: monospace;
65
65
+
66
66
+
&:hover,
67
67
+
&:focus,
68
68
+
&:focus-visible {
69
69
+
border-color: var(--color-input-hover);
70
70
+
}
13
71
}
14
72
15
15
-
a:visited {
16
16
-
color: grey;
73
73
+
input:invalid {
74
74
+
border-color: red;
17
75
}
+1
-1
src/components/AppHeader.vue
···
9
9
font-size: larger;
10
10
font-weight: bolder;
11
11
padding-bottom: 0.5rem;
12
12
-
color: white;
12
12
+
color: var(--color-heading);
13
13
}
14
14
</style>
+39
-30
src/components/AppMain.vue
···
3
3
<div id="search">
4
4
<div class="inputGroup">
5
5
<label for="keyword">Keyword(s)</label>
6
6
-
<input type="text" id="keyword" name="keyword" v-model="keywordsString" />
6
6
+
<input
7
7
+
type="text"
8
8
+
id="keyword"
9
9
+
name="keyword"
10
10
+
pattern="^(?:[^,]+)(?:,\s*[^,]+)*$"
11
11
+
placeholder="keyword(s) (comma-seperated)"
12
12
+
v-model="keywordsString"
13
13
+
/>
7
14
</div>
8
15
<div class="inputGroup">
9
16
<label for="user">User(s)</label>
10
10
-
<input type="text" id="user" name="user" v-model="usersString" />
17
17
+
<input
18
18
+
type="text"
19
19
+
id="user"
20
20
+
name="user"
21
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
22
+
placeholder="handle(s) (comma-seperated)"
23
23
+
v-model="usersString"
24
24
+
/>
11
25
</div>
12
26
<div class="inputGroup">
13
27
<label for="keepNumber">Show last skeets</label>
···
57
71
} else {
58
72
submitWord.value = 'Update'
59
73
connectWebSocket()
60
60
-
setTimeout(updateWebSocket, 500)
61
74
}
62
75
}
63
76
···
69
82
return keywords.value.some((keyword) => skeet.text.includes(keyword))
70
83
}
71
84
85
85
+
const skeetIsFromAuthor = (skeet: Post) => {
86
86
+
if (!users.value) {
87
87
+
return true
88
88
+
}
89
89
+
90
90
+
return userDids.value.find((userDid) => {
91
91
+
return userDid.did === skeet.authorDid
92
92
+
})
93
93
+
}
94
94
+
72
95
const onStop = () => {
73
96
jetstream.value?.close()
74
97
jetstream.value = null
···
84
107
85
108
jetstream.value.onopen = () => {
86
109
console.log('WebSocket connected')
110
110
+
updateWebSocket(true)
87
111
}
88
112
89
113
jetstream.value.onmessage = (event) => {
90
114
const skeet = websocketToFeedEntry(event.data)
91
91
-
if (skeet && skeetContainsKeywords(skeet)) {
115
115
+
if (skeet && skeetContainsKeywords(skeet) && skeetIsFromAuthor(skeet)) {
92
116
const handle = userDids.value.find((userDid) => {
93
117
return userDid.did === skeet.authorDid
94
118
})?.handle
95
95
-
console.log(handle)
96
119
skeets.value.unshift({
97
120
...skeet,
98
121
authorHandle: handle,
···
112
135
const getDids = async (users: string[]): Promise<{ did: string; handle: string }[]> => {
113
136
const results = await Promise.allSettled(
114
137
users.map(async (user) => {
138
138
+
const trimUser = user.trim()
115
139
try {
116
140
const response = await fetch(
117
117
-
`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${user}`,
141
141
+
`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${trimUser}`,
118
142
)
119
143
if (!response.ok) {
120
120
-
throw new Error(`Fehler beim Abrufen von ${user}: ${response.status}`)
144
144
+
throw new Error(`Fehler beim Abrufen von ${trimUser}: ${response.status}`)
121
145
}
122
146
const data = await response.json()
123
123
-
return { did: data.did, handle: user }
147
147
+
return { did: data.did, handle: trimUser }
124
148
} catch (error) {
125
125
-
console.error(`Fehler für ${user}:`, error)
149
149
+
console.error(`Fehler für ${trimUser}:`, error)
126
150
return null // Fehlerhafte Anfragen geben `null` zurück
127
151
}
128
152
}),
···
134
158
.map((result) => (result as PromiseFulfilledResult<{ did: string; handle: string }>).value)
135
159
}
136
160
137
137
-
const updateWebSocket = async () => {
138
138
-
console.info('update user(s) and/or keyword(s)')
139
139
-
let dids: string[] = []
161
161
+
const updateWebSocket = async (silent = false) => {
162
162
+
if (!silent) {
163
163
+
console.info('update user(s) and/or keyword(s)')
164
164
+
}
165
165
+
140
166
userDids.value = []
141
167
if (users.value && users.value[0].length > 1) {
142
168
userDids.value = await getDids(users.value)
143
143
-
dids = userDids.value.map((userDid) => userDid.did)
144
169
}
145
170
jetstream.value?.send(
146
171
JSON.stringify({
147
172
type: 'options_update',
148
173
payload: {
149
174
wantedCollections: ['app.bsky.feed.post'],
150
150
-
wantedDids: dids,
175
175
+
wantedDids: userDids.value.map((userDid) => userDid.did),
151
176
},
152
177
}),
153
178
)
···
185
210
display: flex;
186
211
gap: 1rem;
187
212
padding: 0.5rem 0;
188
188
-
}
189
189
-
190
190
-
button,
191
191
-
input {
192
192
-
border: 2px solid var(--color-text);
193
193
-
background-color: inherit;
194
194
-
color: white;
195
195
-
196
196
-
&:hover,
197
197
-
&:focus {
198
198
-
border-color: white;
199
199
-
}
200
200
-
201
201
-
&:focus-visible {
202
202
-
border-color: red;
203
203
-
}
204
213
}
205
214
206
215
@media screen and (max-width: 371px) {
-16
src/components/SkeetSearch.vue
···
1
1
-
<template>
2
2
-
<form class="search">
3
3
-
<div class="inputGroup">
4
4
-
<label for="keyword">Keyword(s)</label>
5
5
-
<input type="text" id="keyword" name="keyword" />
6
6
-
</div>
7
7
-
<div class="inputGroup">
8
8
-
<label for="user">User(s)</label>
9
9
-
<input type="text" id="user" name="user" />
10
10
-
</div>
11
11
-
</form>
12
12
-
</template>
13
13
-
14
14
-
<script setup lang="ts"></script>
15
15
-
16
16
-
<style lang="scss"></style>
+3
-3
src/components/SkeetView.vue
···
1
1
<template>
2
2
<div class="skeet-view">
3
3
-
<p class="skeet-author" v-if="skeet.authorHandle">{{ skeet.authorHandle }}</p>
4
4
-
<a :href="authorLink" class="skeet-author" target="_blank" v-else>{{ skeet.authorDid }}</a>
3
3
+
<a :href="authorLink" class="skeet-author" target="_blank">{{
4
4
+
skeet.authorHandle ?? skeet.authorDid
5
5
+
}}</a>
5
6
<p class="skeet-date" v-if="skeet.createdAt">{{ skeet.createdAt }}</p>
6
7
<p class="skeet-text">{{ skeet.text }}</p>
7
8
<a :href="skeetLink" class="skeet-link" target="_blank">goto skeet</a>
···
27
28
<style lang="scss">
28
29
.skeet-author {
29
30
font-weight: bold;
30
30
-
color: white;
31
31
}
32
32
.skeet-view {
33
33
padding-top: 1rem;