alpha
Login
or
Join now
kacaii.dev
/
blog
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
💻 My corner on the internet
kacaii.dev/
lustre
evergarden
wisp-place
gleam
atproto
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
blog: fetch bluesky profile on init
author
kacaii.dev
date
5 days ago
(Jul 21, 2026, 3:09 PM -0300)
commit
dee9b454
dee9b454fd450bc175ba99ff60253992cf25bb1e
parent
c4af7105
c4af7105487ab56133ff417644f9832d17df6faf
change-id
lwmqlpqy
lwmqlpqyqvyquulnrxommuwxmtwqnzwk
+142
-116
4 changed files
Expand all
Collapse all
Unified
Split
src
blog
atproto.gleam
page
home.gleam
route.gleam
blog.gleam
+65
-31
src/blog.gleam
View file
Reviewed
···
8
8
import blog/route
9
9
import gleam/bool
10
10
import gleam/list
11
11
+
import gleam/option
11
12
import gleam/string
12
13
import gleam/uri
13
14
import lustre
···
62
63
NavbarTitleUpdated(new: String)
63
64
DocumentTitleUpdated(new: String)
64
65
65
65
-
AtProtoMessage(atproto.Message)
66
66
+
AtProtoMessage(message: atproto.Message)
66
67
}
67
68
68
69
fn update_navbar_title(to new: String) -> effect.Effect(Message) {
···
100
101
atproto.fetch_standard_documents(atproto)
101
102
|> effect.map(AtProtoMessage)
102
103
104
104
+
let fetch_bluesky_profile =
105
105
+
atproto.fetch_bluesky_profile(atproto)
106
106
+
|> effect.map(AtProtoMessage)
107
107
+
103
108
let new_document_title =
104
109
{ "/" <> route.to_string(route) }
105
110
|> update_document_title
106
111
107
112
let effect =
108
108
-
effect.batch([page_effect, init_modem, fetch_documents, new_document_title])
113
113
+
effect.batch([
114
114
+
page_effect,
115
115
+
init_modem,
116
116
+
fetch_documents,
117
117
+
new_document_title,
118
118
+
fetch_bluesky_profile,
119
119
+
])
109
120
110
121
#(Model(title: "kacaii.dev", route:, page:, atproto:), effect)
111
122
}
···
122
133
}
123
134
124
135
route.Home -> {
125
125
-
let #(model, effect) = home.init(atproto)
136
136
+
let #(model, effect) = home.init()
126
137
let page_effect = effect.map(effect, HomeMessage)
127
127
-
let title_effect = case model {
128
128
-
home.Pending -> update_navbar_title("xh --body $PDS")
129
129
-
home.Model(..) -> effect.none()
138
138
+
139
139
+
let title_effect = case atproto.bluesky_profile {
140
140
+
option.None -> update_navbar_title("xh --body $PDS")
141
141
+
option.Some(_) -> effect.none()
130
142
}
131
143
132
144
let effect = effect.batch([page_effect, title_effect])
···
163
175
model, NavbarTitleUpdated(title) -> #(Model(..model, title:), effect.none())
164
176
model, DocumentTitleUpdated(_) -> continue(model)
165
177
166
166
-
Model(page: Home(page), ..), HomeMessage(message) -> {
167
167
-
let #(page, effect) = home.update(page, message)
168
168
-
169
169
-
let page = Home(page)
170
170
-
let effect =
171
171
-
effect.batch([
172
172
-
effect.map(effect, HomeMessage),
173
173
-
update_navbar_title("cd /home/kacaii"),
174
174
-
])
175
175
-
176
176
-
#(Model(..model, page:), effect)
177
177
-
}
178
178
+
Model(page: Home(_), ..), HomeMessage(_) -> continue(model)
179
179
+
Model(page: About(_), ..), AboutMessage(_) -> continue(model)
180
180
+
Model(page: Writing(_), ..), WritingMessage(_) -> continue(model)
178
181
179
182
_model, _message -> continue(model)
180
183
}
···
182
185
183
186
fn handle_atproto_message(model: Model, message: atproto.Message) {
184
187
case model, message {
185
185
-
model, atproto.PdsReturnedDocuments(returned: Ok(standard_documents)) -> {
188
188
+
model,
189
189
+
atproto.PdsReturnedStandardDocuments(returned: Ok(standard_documents))
190
190
+
-> {
191
191
+
let effect =
192
192
+
update_navbar_title("cd " <> route.to_working_directory(model.route))
193
193
+
186
194
let atproto = atproto.AtProto(..model.atproto, standard_documents:)
187
187
-
#(Model(..model, atproto:), effect.none())
195
195
+
#(Model(..model, atproto:), effect)
188
196
}
189
197
190
190
-
model, atproto.PdsReturnedDocuments(returned: Error(_)) -> {
198
198
+
model, atproto.PdsReturnedStandardDocuments(returned: Error(_)) -> {
199
199
+
let effect =
200
200
+
update_navbar_title("cd " <> route.to_working_directory(model.route))
201
201
+
191
202
let atproto = atproto.AtProto(..model.atproto, standard_documents: [])
192
192
-
#(Model(..model, atproto:), effect.none())
203
203
+
#(Model(..model, atproto:), effect)
204
204
+
}
205
205
+
206
206
+
model, atproto.PdsReturnedBlueSkyProfile(Ok(profile)) -> {
207
207
+
let atproto =
208
208
+
atproto.AtProto(..model.atproto, bluesky_profile: option.Some(profile))
209
209
+
210
210
+
let effect =
211
211
+
update_navbar_title("cd " <> route.to_working_directory(model.route))
212
212
+
213
213
+
#(Model(..model, atproto:), effect)
214
214
+
}
215
215
+
216
216
+
model, atproto.PdsReturnedBlueSkyProfile(Error(..)) -> {
217
217
+
let profile =
218
218
+
atproto.BlueskyProfile(
219
219
+
display_name: "Oops!",
220
220
+
pronouns: "im/sorry",
221
221
+
description: "
222
222
+
Aparently my profile failed to load, this was suposed to show my
223
223
+
bluesky photo and description. But its alright, you can still use
224
224
+
the website normally.",
225
225
+
avatar_cid: "",
226
226
+
)
227
227
+
228
228
+
let effect =
229
229
+
update_navbar_title("cd " <> route.to_working_directory(model.route))
230
230
+
231
231
+
let atproto =
232
232
+
atproto.AtProto(..model.atproto, bluesky_profile: option.Some(profile))
233
233
+
#(Model(..model, atproto:), effect)
193
234
}
194
235
}
195
236
}
···
201
242
use <- bool.guard(model.route == route, continue(model))
202
243
let #(page, effect) = init_page(route, model.atproto)
203
244
204
204
-
let new_navbar_title = case route {
205
205
-
route.Home -> "/home/kacaii"
206
206
-
other -> "~/" <> route.to_string(other)
207
207
-
}
208
208
-
209
209
-
let new_page_title = "/" <> route.to_string(route)
210
210
-
211
245
let effect =
212
246
effect.batch([
213
247
effect,
214
214
-
update_navbar_title("cd " <> new_navbar_title),
215
215
-
update_document_title(new_page_title),
248
248
+
update_navbar_title("cd " <> route.to_working_directory(route)),
249
249
+
update_document_title("/" <> route.to_string(route)),
216
250
])
217
251
218
252
#(Model(..model, route:, page:), effect)
+51
-4
src/blog/atproto.gleam
View file
Reviewed
···
10
10
const pds = "amanita.us-east.host.bsky.network"
11
11
12
12
pub type Message {
13
13
-
PdsReturnedDocuments(returned: Result(List(Document), rsvp.Error(String)))
13
13
+
PdsReturnedStandardDocuments(
14
14
+
returned: Result(List(Document), rsvp.Error(String)),
15
15
+
)
16
16
+
17
17
+
PdsReturnedBlueSkyProfile(
18
18
+
returned: Result(BlueSkyProfile, rsvp.Error(String)),
19
19
+
)
14
20
}
15
21
16
22
pub type AtProto {
···
18
24
did: did.Did,
19
25
pds: String,
20
26
standard_documents_nsdi: nsid.Nsid,
27
27
+
standard_documents: List(Document),
21
28
bluesky_profile_nsid: nsid.Nsid,
22
22
-
standard_documents: List(Document),
29
29
+
bluesky_profile: option.Option(BlueSkyProfile),
23
30
)
24
31
}
25
32
···
32
39
did:,
33
40
pds:,
34
41
bluesky_profile_nsid:,
42
42
+
standard_documents: [],
35
43
standard_documents_nsdi:,
36
36
-
standard_documents: [],
44
44
+
bluesky_profile: option.None,
37
45
)
38
46
}
39
47
···
91
99
reverse: option.None,
92
100
)
93
101
94
94
-
let handler = rsvp.expect_json(decoder, PdsReturnedDocuments)
102
102
+
let handler = rsvp.expect_json(decoder, PdsReturnedStandardDocuments)
95
103
rsvp.send(request, handler)
96
104
}
105
105
+
106
106
+
pub type BlueSkyProfile {
107
107
+
BlueskyProfile(
108
108
+
display_name: String,
109
109
+
pronouns: String,
110
110
+
description: String,
111
111
+
avatar_cid: String,
112
112
+
)
113
113
+
}
114
114
+
115
115
+
pub fn fetch_bluesky_profile(atproto: AtProto) -> effect.Effect(Message) {
116
116
+
let decoder = {
117
117
+
use display_name <- decode.field("displayName", decode.string)
118
118
+
use description <- decode.field("description", decode.string)
119
119
+
use pronouns <- decode.field("pronouns", decode.string)
120
120
+
use avatar_cid <- decode.subfield(["avatar", "ref", "$link"], decode.string)
121
121
+
122
122
+
decode.success(BlueskyProfile(
123
123
+
display_name:,
124
124
+
pronouns:,
125
125
+
description:,
126
126
+
avatar_cid:,
127
127
+
))
128
128
+
}
129
129
+
130
130
+
let request =
131
131
+
possum.get_record(
132
132
+
atproto.did,
133
133
+
pds: "slingshot.microcosm.blue",
134
134
+
collection: atproto.bluesky_profile_nsid,
135
135
+
rkey: "self",
136
136
+
cid: option.None,
137
137
+
)
138
138
+
139
139
+
rsvp.send(
140
140
+
request,
141
141
+
rsvp.expect_json(decode.at(["value"], decoder), PdsReturnedBlueSkyProfile),
142
142
+
)
143
143
+
}
+19
-81
src/blog/page/home.gleam
View file
Reviewed
···
1
1
import blog/atproto
2
2
import blog/icon
3
3
-
import gleam/dynamic/decode
4
3
import gleam/http/request
5
4
import gleam/list
6
5
import gleam/option
···
13
12
import lustre/element/keyed
14
13
import possum
15
14
import possum/did
16
16
-
import rsvp
17
15
18
16
pub type Message {
19
19
-
ApiReturnedProfile(Result(Profile, rsvp.Error(String)))
17
17
+
Message
20
18
}
21
19
22
20
pub type Model {
23
23
-
Pending
24
24
-
Model(profile: Profile)
25
25
-
}
26
26
-
27
27
-
pub type Profile {
28
28
-
Profile(
29
29
-
display_name: String,
30
30
-
pronouns: String,
31
31
-
description: String,
32
32
-
avatar_cid: String,
33
33
-
)
34
34
-
}
35
35
-
36
36
-
fn fetch_bluesky_profile(atproto: atproto.AtProto) -> effect.Effect(Message) {
37
37
-
let decoder = {
38
38
-
use display_name <- decode.field("displayName", decode.string)
39
39
-
use description <- decode.field("description", decode.string)
40
40
-
use pronouns <- decode.field("pronouns", decode.string)
41
41
-
use avatar_cid <- decode.subfield(["avatar", "ref", "$link"], decode.string)
42
42
-
decode.success(Profile(display_name:, pronouns:, description:, avatar_cid:))
43
43
-
}
44
44
-
45
45
-
let request =
46
46
-
possum.get_record(
47
47
-
atproto.did,
48
48
-
pds: "slingshot.microcosm.blue",
49
49
-
collection: atproto.bluesky_profile_nsid,
50
50
-
rkey: "self",
51
51
-
cid: option.None,
52
52
-
)
53
53
-
54
54
-
rsvp.send(
55
55
-
request,
56
56
-
rsvp.expect_json(decode.at(["value"], decoder), ApiReturnedProfile),
57
57
-
)
21
21
+
Model
58
22
}
59
23
60
60
-
pub fn init(atproto: atproto.AtProto) -> #(Model, effect.Effect(Message)) {
61
61
-
#(Pending, fetch_bluesky_profile(atproto))
24
24
+
pub fn init() -> #(Model, effect.Effect(Message)) {
25
25
+
#(Model, effect.none())
62
26
}
63
27
64
28
pub fn update(
65
65
-
model: Model,
66
66
-
message: Message,
29
29
+
_model: Model,
30
30
+
_message: Message,
67
31
) -> #(Model, effect.Effect(Message)) {
68
68
-
case model, message {
69
69
-
Pending, ApiReturnedProfile(Ok(profile)) -> #(
70
70
-
Model(profile:),
71
71
-
effect.none(),
72
72
-
)
73
73
-
74
74
-
Pending, ApiReturnedProfile(Error(..)) -> {
75
75
-
let profile =
76
76
-
Profile(
77
77
-
display_name: "Oops!",
78
78
-
pronouns: "im/sorry",
79
79
-
description: "
80
80
-
Aparently my profile failed to load, this was suposed to show my
81
81
-
bluesky photo and description. But its alright, you can still use
82
82
-
the website normally.",
83
83
-
avatar_cid: "",
84
84
-
)
85
85
-
86
86
-
let effect = effect.none()
87
87
-
#(Model(profile:), effect)
88
88
-
}
89
89
-
90
90
-
_, _ -> #(model, effect.none())
91
91
-
}
32
32
+
#(Model, effect.none())
92
33
}
93
34
94
35
pub fn view(
95
95
-
model: Model,
36
36
+
_model: Model,
96
37
atproto: atproto.AtProto,
97
38
) -> element.Element(Message) {
98
39
let rss_href = attr.href("https://blog.kacaii.dev/rss")
···
101
42
102
43
let html = [
103
44
html.h1([], [html.text("home")]),
104
104
-
view_profile_card(model, atproto),
45
45
+
view_profile_card(atproto),
105
46
view_introduction(rss_href, bluesky_href),
106
47
view_showcases(),
107
48
]
···
109
50
element.fragment(html)
110
51
}
111
52
112
112
-
fn view_profile_card(
113
113
-
model: Model,
114
114
-
atproto: atproto.AtProto,
115
115
-
) -> element.Element(Message) {
116
116
-
let image = case model {
117
117
-
Pending ->
53
53
+
fn view_profile_card(atproto: atproto.AtProto) -> element.Element(Message) {
54
54
+
let image = case atproto.bluesky_profile {
55
55
+
option.None ->
118
56
html.img([
119
57
attr.src("/favicon.webp"),
120
58
attr.class("border-muted size-20 rounded-sm border"),
121
59
attr.class("aspect-square animate-pulse opacity-50"),
122
60
])
123
61
124
124
-
Model(profile:) ->
62
62
+
option.Some(profile) ->
125
63
html.img([
126
64
attr.class("aspect-square size-20 rounded-sm"),
127
65
attr.class("hover:brightness-110"),
···
152
90
image,
153
91
html.div([attr.class("w-full grid-rows-2 space-y-1")], [
154
92
// Username and pronouns
155
155
-
case model {
156
156
-
Pending -> {
93
93
+
case atproto.bluesky_profile {
94
94
+
option.None -> {
157
95
html.div([attr.class("mb-2 flex gap-2")], [
158
96
skeleton([attr.class("h-4 w-11")]),
159
97
skeleton([attr.class("h-4 w-18")]),
160
98
])
161
99
}
162
100
163
163
-
Model(profile:) -> {
101
101
+
option.Some(profile) -> {
164
102
let wrapper_style = attr.class("hidden gap-2 sm:inline-flex")
165
103
let username_style = attr.class("font-bold")
166
104
···
172
110
},
173
111
174
112
// Description
175
175
-
case model {
176
176
-
Pending ->
113
113
+
case atproto.bluesky_profile {
114
114
+
option.None ->
177
115
html.div([attr.class("flex flex-col space-y-2")], [
178
116
skeleton([attr.class("h-4")]),
179
117
skeleton([attr.class("h-4 w-49")]),
180
118
])
181
119
182
182
-
Model(profile:) ->
120
120
+
option.Some(profile) ->
183
121
html.p([attr.class("text-sm sm:text-base")], [
184
122
html.text(profile.description),
185
123
])
+7
src/blog/route.gleam
View file
Reviewed
···
36
36
}
37
37
}
38
38
39
39
+
pub fn to_working_directory(self: Route) -> String {
40
40
+
case self {
41
41
+
Home -> "/home/kacaii"
42
42
+
other -> "~/" <> to_string(other)
43
43
+
}
44
44
+
}
45
45
+
39
46
pub fn href(self: Route) -> attr.Attribute(_) {
40
47
path(self)
41
48
|> attr.href