Skip to content

Commit 3dec389

Browse files
feat(marketing): add product hunt badge (#10)
1 parent a7186cd commit 3dec389

File tree

7 files changed

+271
-55
lines changed

7 files changed

+271
-55
lines changed

fluxer_marketing/src/fluxer_marketing.gleam

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
//// You should have received a copy of the GNU Affero General Public License
1616
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
1717

18+
import fluxer_marketing/badge_proxy
1819
import fluxer_marketing/config
1920
import fluxer_marketing/geoip
2021
import fluxer_marketing/i18n
@@ -24,7 +25,6 @@ import fluxer_marketing/middleware/cache_middleware
2425
import fluxer_marketing/router
2526
import fluxer_marketing/visionary_slots
2627
import fluxer_marketing/web
27-
import gleam/erlang/atom.{type Atom}
2828
import gleam/erlang/process
2929
import gleam/http/request
3030
import gleam/list
@@ -40,15 +40,18 @@ pub fn main() {
4040
let assert Ok(cfg) = config.load_config()
4141

4242
let i18n_db = i18n.setup_database()
43+
4344
let slots_cache =
4445
visionary_slots.start(visionary_slots.Settings(
4546
api_host: cfg.api_host,
4647
rpc_secret: cfg.gateway_rpc_secret,
4748
))
4849

50+
let badge_cache = badge_proxy.start_cache()
51+
4952
let assert Ok(_) =
5053
wisp_mist.handler(
51-
handle_request(_, i18n_db, cfg, slots_cache),
54+
handle_request(_, i18n_db, cfg, slots_cache, badge_cache),
5255
cfg.secret_key_base,
5356
)
5457
|> mist.new
@@ -64,11 +67,11 @@ fn handle_request(
6467
i18n_db,
6568
cfg: config.Config,
6669
slots_cache: visionary_slots.Cache,
70+
badge_cache: badge_proxy.Cache,
6771
) -> wisp.Response {
6872
let locale = get_request_locale(req)
6973

7074
let base_url = cfg.marketing_endpoint <> cfg.base_path
71-
7275
let country_code = geoip.country_code(req, cfg.geoip_host)
7376

7477
let user_agent = case request.get_header(req, "user-agent") {
@@ -97,6 +100,7 @@ fn handle_request(
97100
release_channel: cfg.release_channel,
98101
visionary_slots: visionary_slots.current(slots_cache),
99102
metrics_endpoint: cfg.metrics_endpoint,
103+
badge_cache: badge_cache,
100104
)
101105

102106
use <- wisp.log_request(req)
@@ -116,18 +120,21 @@ fn handle_request(
116120
}
117121

118122
let duration = monotonic_milliseconds() - start
119-
120123
metrics.track_request(ctx, req, response.status, duration)
121124

122125
response |> cache_middleware.add_cache_headers
123126
}
124127

125-
fn monotonic_milliseconds() -> Int {
126-
do_monotonic_time(atom.create("millisecond"))
128+
type TimeUnit {
129+
Millisecond
127130
}
128131

129132
@external(erlang, "erlang", "monotonic_time")
130-
fn do_monotonic_time(unit: Atom) -> Int
133+
fn erlang_monotonic_time(unit: TimeUnit) -> Int
134+
135+
fn monotonic_milliseconds() -> Int {
136+
erlang_monotonic_time(Millisecond)
137+
}
131138

132139
fn get_request_locale(req: wisp.Request) -> Locale {
133140
case wisp.get_cookie(req, "locale", wisp.PlainText) {
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
//// Copyright (C) 2026 Fluxer Contributors
2+
////
3+
//// This file is part of Fluxer.
4+
////
5+
//// Fluxer is free software: you can redistribute it and/or modify
6+
//// it under the terms of the GNU Affero General Public License as published by
7+
//// the Free Software Foundation, either version 3 of the License, or
8+
//// (at your option) any later version.
9+
////
10+
//// Fluxer is distributed in the hope that it will be useful,
11+
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
//// GNU Affero General Public License for more details.
14+
////
15+
//// You should have received a copy of the GNU Affero General Public License
16+
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
17+
18+
import gleam/erlang/process
19+
import gleam/http/request
20+
import gleam/httpc
21+
import gleam/option.{type Option}
22+
import wisp.{type Response}
23+
24+
const product_hunt_url = "https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1057558&theme=light"
25+
26+
const stale_after_ms = 300_000
27+
28+
const receive_timeout_ms = 5000
29+
30+
const fetch_timeout_ms = 4500
31+
32+
pub opaque type Cache {
33+
Cache(subject: process.Subject(ServerMessage))
34+
}
35+
36+
type ServerMessage {
37+
Get(process.Subject(Option(String)))
38+
RefreshDone(fetched_at: Int, svg: Option(String))
39+
}
40+
41+
type CacheEntry {
42+
CacheEntry(svg: String, fetched_at: Int)
43+
}
44+
45+
type State {
46+
State(cache: Option(CacheEntry), is_refreshing: Bool)
47+
}
48+
49+
pub fn start_cache() -> Cache {
50+
let started = process.new_subject()
51+
let _ = process.spawn_unlinked(fn() { run(started) })
52+
53+
let assert Ok(subject) = process.receive(started, within: 1000)
54+
Cache(subject: subject)
55+
}
56+
57+
fn run(started: process.Subject(process.Subject(ServerMessage))) {
58+
let subject = process.new_subject()
59+
process.send(started, subject)
60+
61+
let initial = State(cache: option.None, is_refreshing: False)
62+
loop(subject, initial)
63+
}
64+
65+
fn loop(subject: process.Subject(ServerMessage), state: State) {
66+
let new_state = case process.receive(subject, within: stale_after_ms) {
67+
Ok(Get(reply_to)) -> handle_get(subject, reply_to, state)
68+
69+
Ok(RefreshDone(fetched_at, svg)) ->
70+
handle_refresh_done(fetched_at, svg, state)
71+
72+
Error(_) -> maybe_refresh_in_background(subject, state)
73+
}
74+
75+
loop(subject, new_state)
76+
}
77+
78+
fn handle_get(
79+
subject: process.Subject(ServerMessage),
80+
reply_to: process.Subject(Option(String)),
81+
state: State,
82+
) -> State {
83+
let now = monotonic_time_ms()
84+
85+
case state.cache {
86+
option.None -> {
87+
let svg = fetch_badge_svg()
88+
process.send(reply_to, svg)
89+
90+
let new_cache = case svg {
91+
option.Some(content) ->
92+
option.Some(CacheEntry(svg: content, fetched_at: now))
93+
option.None -> option.None
94+
}
95+
96+
State(cache: new_cache, is_refreshing: False)
97+
}
98+
99+
option.Some(entry) -> {
100+
let is_stale = now - entry.fetched_at > stale_after_ms
101+
102+
process.send(reply_to, option.Some(entry.svg))
103+
104+
case is_stale && !state.is_refreshing {
105+
True -> {
106+
spawn_refresh(subject)
107+
State(..state, is_refreshing: True)
108+
}
109+
False -> state
110+
}
111+
}
112+
}
113+
}
114+
115+
fn handle_refresh_done(
116+
fetched_at: Int,
117+
svg: Option(String),
118+
state: State,
119+
) -> State {
120+
let new_cache = case svg {
121+
option.Some(content) ->
122+
option.Some(CacheEntry(svg: content, fetched_at: fetched_at))
123+
option.None -> state.cache
124+
}
125+
126+
State(cache: new_cache, is_refreshing: False)
127+
}
128+
129+
fn maybe_refresh_in_background(
130+
subject: process.Subject(ServerMessage),
131+
state: State,
132+
) -> State {
133+
let now = monotonic_time_ms()
134+
135+
case state.cache, state.is_refreshing {
136+
option.Some(entry), False if now - entry.fetched_at > stale_after_ms -> {
137+
spawn_refresh(subject)
138+
State(..state, is_refreshing: True)
139+
}
140+
141+
_, _ -> state
142+
}
143+
}
144+
145+
fn spawn_refresh(subject: process.Subject(ServerMessage)) {
146+
let _ =
147+
process.spawn_unlinked(fn() {
148+
let fetched_at = monotonic_time_ms()
149+
let svg = fetch_badge_svg()
150+
process.send(subject, RefreshDone(fetched_at, svg))
151+
})
152+
153+
Nil
154+
}
155+
156+
pub fn get_badge(cache: Cache) -> Option(String) {
157+
let reply_to = process.new_subject()
158+
process.send(cache.subject, Get(reply_to))
159+
160+
case process.receive(reply_to, within: receive_timeout_ms) {
161+
Ok(svg) -> svg
162+
Error(_) -> option.None
163+
}
164+
}
165+
166+
pub fn product_hunt(cache: Cache) -> Response {
167+
case get_badge(cache) {
168+
option.Some(content) -> {
169+
wisp.response(200)
170+
|> wisp.set_header("content-type", "image/svg+xml")
171+
|> wisp.set_header(
172+
"cache-control",
173+
"public, max-age=300, stale-while-revalidate=600",
174+
)
175+
|> wisp.set_header("vary", "Accept")
176+
|> wisp.string_body(content)
177+
}
178+
179+
option.None -> {
180+
wisp.response(503)
181+
|> wisp.set_header("content-type", "text/plain")
182+
|> wisp.set_header("retry-after", "60")
183+
|> wisp.string_body("Badge temporarily unavailable")
184+
}
185+
}
186+
}
187+
188+
fn fetch_badge_svg() -> Option(String) {
189+
let assert Ok(req0) = request.to(product_hunt_url)
190+
let req =
191+
req0
192+
|> request.prepend_header("accept", "image/svg+xml")
193+
|> request.prepend_header("user-agent", "FluxerMarketing/1.0")
194+
195+
let config =
196+
httpc.configure()
197+
|> httpc.timeout(fetch_timeout_ms)
198+
199+
case httpc.dispatch(config, req) {
200+
Ok(resp) if resp.status >= 200 && resp.status < 300 ->
201+
option.Some(resp.body)
202+
_ -> option.None
203+
}
204+
}
205+
206+
type TimeUnit {
207+
Millisecond
208+
}
209+
210+
@external(erlang, "erlang", "monotonic_time")
211+
fn erlang_monotonic_time(unit: TimeUnit) -> Int
212+
213+
fn monotonic_time_ms() -> Int {
214+
erlang_monotonic_time(Millisecond)
215+
}

fluxer_marketing/src/fluxer_marketing/components/hackernews_banner.gleam

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,40 +22,6 @@ import lustre/attribute
2222
import lustre/element.{type Element}
2323
import lustre/element/html
2424

25-
fn hn_logo() -> Element(a) {
26-
element.element(
27-
"svg",
28-
[
29-
attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
30-
attribute.attribute("viewBox", "4 4 188 188"),
31-
attribute.attribute("width", "20"),
32-
attribute.attribute("height", "20"),
33-
attribute.class("block flex-shrink-0 rounded-[3px]"),
34-
],
35-
[
36-
element.element(
37-
"path",
38-
[
39-
attribute.attribute("d", "m4 4h188v188h-188z"),
40-
attribute.attribute("fill", "#f60"),
41-
],
42-
[],
43-
),
44-
element.element(
45-
"path",
46-
[
47-
attribute.attribute(
48-
"d",
49-
"m73.2521756 45.01 22.7478244 47.39130083 22.7478244-47.39130083h19.56569631l-34.32352071 64.48661468v41.49338532h-15.98v-41.49338532l-34.32352071-64.48661468z",
50-
),
51-
attribute.attribute("fill", "#fff"),
52-
],
53-
[],
54-
),
55-
],
56-
)
57-
}
58-
5925
pub fn render(ctx: Context) -> Element(a) {
6026
let i18n_ctx = i18n.get_context(ctx.i18n_db, ctx.locale)
6127

@@ -66,12 +32,7 @@ pub fn render(ctx: Context) -> Element(a) {
6632
),
6733
],
6834
[
69-
hn_logo(),
7035
html.p([attribute.class("text-sm md:text-base text-white/90")], [
71-
html.span([attribute.class("font-medium")], [
72-
html.text(g_(i18n_ctx, "From HN?")),
73-
html.text(" "),
74-
]),
7536
html.text(g_(i18n_ctx, "Try it without an email at")),
7637
html.text(" "),
7738
html.a(

fluxer_marketing/src/fluxer_marketing/components/hero.gleam

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import fluxer_marketing/components/hackernews_banner
1919
import fluxer_marketing/components/platform_download_button
2020
import fluxer_marketing/i18n
2121
import fluxer_marketing/locale
22-
import fluxer_marketing/web.{type Context}
22+
import fluxer_marketing/web.{type Context, prepend_base_path}
2323
import kielet.{gettext as g_}
2424
import lustre/attribute
2525
import lustre/element.{type Element}
@@ -84,6 +84,27 @@ pub fn render(ctx: Context) -> Element(a) {
8484
],
8585
),
8686
hackernews_banner.render(ctx),
87+
html.div([attribute.class("mt-6 flex justify-center")], [
88+
html.a(
89+
[
90+
attribute.href(
91+
"https://www.producthunt.com/products/fluxer?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-fluxer",
92+
),
93+
attribute.target("_blank"),
94+
attribute.attribute("rel", "noopener noreferrer"),
95+
],
96+
[
97+
html.img([
98+
attribute.alt(
99+
"Fluxer - Open-source Discord-like instant messaging & VoIP platform | Product Hunt",
100+
),
101+
attribute.attribute("width", "250"),
102+
attribute.attribute("height", "54"),
103+
attribute.src(prepend_base_path(ctx, "/api/badges/product-hunt")),
104+
]),
105+
],
106+
),
107+
]),
87108
]),
88109
html.div(
89110
[

0 commit comments

Comments
 (0)