Use the uploaded avatar everywhere the identicon was

709545e · Gabriel Morell · 2026-09-13 00:28

10 files +164 -4
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/avatars.ex
+56 −0
@@ -25,6 +25,7 @@ defmodule GitGud.Avatars do
25 25 import Ecto.Query, warn: false
26 26
27 27 alias GitGud.Accounts.User
28 + alias GitGud.Cache
28 29 alias GitGud.Avatars.Avatar
29 30 alias GitGud.Avatars.Identicon
30 31 alias GitGud.Organizations.Organization
@@ -35,6 +36,8 @@ defmodule GitGud.Avatars do
35 36
36 37 @bucket "gitgud-avatars"
37 38
39 + @handles_key {:avatars, :uploaded_handles}
40 +
38 41 # Generous for a picture that renders at 96px, tight enough that a
39 42 # misdirected upload fails fast rather than filling a bucket.
40 43 @max_bytes 2 * 1024 * 1024
@@ -90,6 +93,7 @@ defmodule GitGud.Avatars do
90 93 # that is already gone.
91 94 with {:ok, avatar} <- result do
92 95 discard(previous, avatar)
96 + invalidate_handles()
93 97 {:ok, avatar}
94 98 end
95 99 end
@@ -106,6 +110,7 @@ defmodule GitGud.Avatars do
106 110 avatar ->
107 111 Repo.delete(avatar)
108 112 _ = Storage.delete(avatar.storage_bucket, avatar.storage_key)
113 + invalidate_handles()
109 114 :ok
110 115 end
111 116 end
@@ -187,6 +192,57 @@ defmodule GitGud.Avatars do
187 192 "#{RepoStorage.repo_handle(repo)}/#{repo.name}"
188 193 end
189 194
195 + @doc """
196 + The handles that actually have an uploaded avatar.
197 +
198 + `AvatarComponent` renders an `<img>` for these and its themed SVG
199 + identicon for everyone else, and it is asked once per avatar on a
200 + page — a members list is fifty of them. So this is one cached query
201 + returning a set of strings rather than a lookup per render, and the
202 + set is small by nature: it holds only the people who have bothered.
203 +
204 + Cached rather than computed per request, and invalidated on write, so
205 + an upload shows up immediately rather than within a TTL. Getting that
206 + backwards is how an avatar feature earns "I changed it and nothing
207 + happened".
208 + """
209 + @spec uploaded_handles() :: MapSet.t(String.t())
210 + def uploaded_handles do
211 + case Cache.get(@handles_key) do
212 + nil ->
213 + handles = load_uploaded_handles()
214 + Cache.put(@handles_key, handles, ttl: :timer.hours(1))
215 + handles
216 +
217 + handles ->
218 + handles
219 + end
220 + end
221 +
222 + defp load_uploaded_handles do
223 + users =
224 + from(a in Avatar,
225 + join: u in User,
226 + on: u.id == a.owner_id,
227 + where: a.owner_type == "user" and not is_nil(u.handle),
228 + select: u.handle
229 + )
230 + |> Repo.all()
231 +
232 + orgs =
233 + from(a in Avatar,
234 + join: o in Organization,
235 + on: o.id == a.owner_id,
236 + where: a.owner_type == "organization",
237 + select: o.handle
238 + )
239 + |> Repo.all()
240 +
241 + MapSet.new(users ++ orgs)
242 + end
243 +
244 + defp invalidate_handles, do: Cache.delete(@handles_key)
245 +
190 246 @doc "Largest upload accepted, in bytes."
191 247 def max_bytes, do: @max_bytes
192 248
modified lib/git_gud_web/components/avatar_component.ex
+49 −4
@@ -3,9 +3,14 @@ defmodule GitGudWeb.AvatarComponent do
3 3 Deterministic identicon-style avatars that pull their colors from the
4 4 active daisyUI theme.
5 5
6 − No image storage. The avatar is an inline SVG whose `fill`s reference
7 − CSS custom properties (`var(--color-primary)`, etc.) — so switching
8 − themes recolors every avatar on the page without re-rendering.
6 + An owner who has uploaded an avatar gets an `<img>`; everyone else
7 + gets an inline SVG whose `fill`s reference CSS custom properties
8 + (`var(--color-primary)`, etc.), so switching themes recolours every
9 + generated avatar on the page without re-rendering.
10 +
11 + Which way round matters: most people never upload anything, and
12 + serving them a static PNG to avoid a branch would lose the theming
13 + for the majority to benefit the few.
9 14
10 15 Pattern: 5×5 grid of cells, left half + center driven by 15 bits from
11 16 a SHA-256 of the seed name; right half mirrors the left. Identicon
@@ -38,16 +43,29 @@ defmodule GitGudWeb.AvatarComponent do
38 43 attr :class, :string, default: ""
39 44
40 45 def avatar(assigns) do
41 − {fg_color, cells} = render_spec(assigns.name || "?")
46 + name = assigns.name || "?"
47 + {fg_color, cells} = render_spec(name)
42 48
43 49 assigns =
44 50 assigns
45 51 |> assign(:size_class, Map.get(@sizes, assigns.size, "size-8"))
46 52 |> assign(:fg_color, fg_color)
47 53 |> assign(:cells, cells)
54 + |> assign(:src, uploaded_src(name))
48 55
49 56 ~H"""
57 + <img
58 + :if={@src}
59 + src={@src}
60 + alt={@alt}
61 + class={[
62 + "inline-block rounded-full bg-base-200 object-cover align-middle shrink-0",
63 + @size_class,
64 + @class
65 + ]}
66 + />
50 67 <span
68 + :if={!@src}
51 69 class={[
52 70 "inline-block overflow-hidden rounded-full bg-base-200 align-middle shrink-0",
53 71 @size_class,
@@ -70,6 +88,33 @@ defmodule GitGudWeb.AvatarComponent do
70 88 """
71 89 end
72 90
91 + # An uploaded avatar's URL, or nil when there isn't one.
92 + #
93 + # Consulted per avatar and a members list is fifty of them, so this
94 + # reads a cached set of the handles that have uploads rather than
95 + # querying per render. Everyone else keeps the SVG below, which is
96 + # the point: the identicon recolours with the theme, and swapping it
97 + # for a static PNG everywhere would be a quiet regression for the
98 + # majority who never upload anything.
99 + #
100 + # No version in the URL, deliberately. Adding one costs a lookup per
101 + # avatar for a five-minute cache window, and a face being briefly
102 + # stale in a comment thread is not worth fifty queries.
103 + #
104 + # Rescued because this is the one piece of I/O in an otherwise pure
105 + # presentation component. "I could not find out whether there is an
106 + # upload" has an obviously right answer — draw the generated one —
107 + # and an avatar is not worth failing a page render over. It also
108 + # keeps the component renderable without a database, which is how its
109 + # own tests exercise it.
110 + defp uploaded_src(name) do
111 + if MapSet.member?(GitGud.Avatars.uploaded_handles(), name) do
112 + "/u/#{name}/avatar"
113 + end
114 + rescue
115 + _ -> nil
116 + end
117 +
73 118 @doc """
74 119 For a given seed name, return `{fg_color, cells}` where `fg_color` is
75 120 the chosen daisyUI palette slot (without the `--color-` prefix) and
modified test/git_gud_web/live/avatar_upload_test.exs
+59 −0
@@ -89,6 +89,65 @@ defmodule GitGudWeb.AvatarUploadTest do
89 89 end
90 90 end
91 91
92 + describe "showing up everywhere the identicon did" do
93 + test "a profile shows the upload once there is one", %{conn: conn, user: user} do
94 + {:ok, _view, before} = live(conn, ~p"/u/#{user.handle}")
95 + # No upload yet: the themed SVG, which recolours with the theme.
96 + assert before =~ "<svg"
97 + refute before =~ ~s(src="/u/#{user.handle}/avatar")
98 +
99 + {:ok, _} = Avatars.put(user, @png)
100 +
101 + {:ok, _view, after_upload} = live(conn, ~p"/u/#{user.handle}")
102 + assert after_upload =~ ~s(src="/u/#{user.handle}/avatar")
103 + end
104 +
105 + test "an org listing picks it up too", %{conn: conn, user: user} do
106 + {:ok, org} =
107 + GitGud.Organizations.create_organization(user, %{
108 + "handle" => "avatarorg",
109 + "name" => "Avatar Org"
110 + })
111 +
112 + {:ok, _} = Avatars.put(org, @png)
113 +
114 + {:ok, _view, html} = live(conn, ~p"/orgs/#{org.handle}")
115 + assert html =~ ~s(src="/u/avatarorg/avatar")
116 + end
117 +
118 + test "everyone without an upload keeps the themed identicon", %{conn: conn, user: user} do
119 + {:ok, _view, html} = live(conn, ~p"/u/#{user.handle}")
120 +
121 + # The majority never upload anything; swapping them to a static
122 + # PNG to avoid a branch would lose theming for most to benefit
123 + # the few.
124 + assert html =~ "var(--color-"
125 + end
126 +
127 + test "an upload is visible immediately, not after a TTL", %{conn: conn, user: user} do
128 + # Warm the cache with the pre-upload answer.
129 + {:ok, _view, _html} = live(conn, ~p"/u/#{user.handle}")
130 + refute MapSet.member?(Avatars.uploaded_handles(), user.handle)
131 +
132 + {:ok, _} = Avatars.put(user, @png)
133 +
134 + # Invalidated on write. Getting this backwards is how an avatar
135 + # feature earns "I changed it and nothing happened".
136 + assert MapSet.member?(Avatars.uploaded_handles(), user.handle)
137 + {:ok, _view, html} = live(conn, ~p"/u/#{user.handle}")
138 + assert html =~ ~s(src="/u/#{user.handle}/avatar")
139 + end
140 +
141 + test "removing goes back to the identicon", %{conn: conn, user: user} do
142 + {:ok, _} = Avatars.put(user, @png)
143 + :ok = Avatars.delete(user)
144 +
145 + refute MapSet.member?(Avatars.uploaded_handles(), user.handle)
146 + {:ok, _view, html} = live(conn, ~p"/u/#{user.handle}")
147 + assert html =~ "var(--color-"
148 + end
149 + end
150 +
92 151 describe "serving" do
93 152 test "returns the identicon for an owner with no upload", %{conn: conn, user: user} do
94 153 conn = get(conn, ~p"/u/#{user.handle}/avatar")

Parents: c6afc6c