neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Use the uploaded avatar everywhere the identicon was
709545e · Gabriel Morell · 2026-09-13 00:28
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