Skip to content

Commit 7fca12f

Browse files
fix(core): hydrate byline avatar LQIP placeholder fields (emdash-cms#1457) (emdash-cms#1553)
Content byline credits fold in the avatar's storage key and alt via the media join, but dropped the blurhash/dominant_color LQIP columns, so author avatars couldn't render a low-quality placeholder despite the media row storing one. Carry m.blurhash/m.dominant_color through the same media join in getContentBylines, getContentBylinesMany, and findByUserIds, surface them as BylineSummary.avatarBlurhash/avatarDominantColor (and in the byline API response schema), and default to null on finders that don't join media. Purely additive, mirroring the existing avatarStorageKey pattern. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6b036c4 commit 7fca12f

5 files changed

Lines changed: 58 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Byline avatar hydration now includes the media LQIP placeholder fields (#1457). Content byline credits already folded in the avatar's `avatarStorageKey` and `avatarAlt` via the media join, but dropped the `blurhash`/`dominant_color` placeholder columns, so author avatars couldn't render a low-quality image placeholder while loading even though the media stored one. `BylineSummary` now also carries `avatarBlurhash` and `avatarDominantColor`, populated by `getContentBylines`, `getContentBylinesMany`, and `findByUserIds` (and surfaced in the byline API response), so themes can paint a blurhash/dominant-colour placeholder for byline avatars exactly as they can for other media.

packages/core/src/api/schemas/bylines.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ export const bylineSummarySchema = z
1919
*/
2020
avatarStorageKey: z.string().nullish(),
2121
avatarAlt: z.string().nullish(),
22+
/**
23+
* Avatar media LQIP placeholder (blurhash + dominant colour, migration
24+
* 024), from the same media join. Lets clients render a placeholder
25+
* while the avatar loads. Null under the same conditions as
26+
* `avatarStorageKey`.
27+
*/
28+
avatarBlurhash: z.string().nullish(),
29+
avatarDominantColor: z.string().nullish(),
2230
websiteUrl: z.string().nullable(),
2331
userId: z.string().nullable(),
2432
isGuest: z.boolean(),

packages/core/src/database/repositories/byline.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ type BylineRow = Selectable<BylineTable>;
3737
type BylineRowWithAvatar = BylineRow & {
3838
avatar_storage_key?: string | null;
3939
avatar_alt?: string | null;
40+
avatar_blurhash?: string | null;
41+
avatar_dominant_color?: string | null;
4042
};
4143

4244
export interface CreateBylineInput {
@@ -113,6 +115,8 @@ function rowToByline(row: BylineRowWithAvatar): BylineSummary {
113115
avatarMediaId: row.avatar_media_id,
114116
avatarStorageKey: row.avatar_storage_key ?? null,
115117
avatarAlt: row.avatar_alt ?? null,
118+
avatarBlurhash: row.avatar_blurhash ?? null,
119+
avatarDominantColor: row.avatar_dominant_color ?? null,
116120
websiteUrl: row.website_url,
117121
userId: row.user_id,
118122
isGuest: row.is_guest === 1,
@@ -944,6 +948,8 @@ export class BylineRepository {
944948
"b.avatar_media_id as avatar_media_id",
945949
"m.storage_key as avatar_storage_key",
946950
"m.alt as avatar_alt",
951+
"m.blurhash as avatar_blurhash",
952+
"m.dominant_color as avatar_dominant_color",
947953
"b.website_url as website_url",
948954
"b.user_id as user_id",
949955
"b.is_guest as is_guest",
@@ -971,6 +977,8 @@ export class BylineRepository {
971977
avatar_media_id: row.avatar_media_id,
972978
avatar_storage_key: row.avatar_storage_key,
973979
avatar_alt: row.avatar_alt,
980+
avatar_blurhash: row.avatar_blurhash,
981+
avatar_dominant_color: row.avatar_dominant_color,
974982
website_url: row.website_url,
975983
user_id: row.user_id,
976984
is_guest: row.is_guest,
@@ -1080,6 +1088,8 @@ export class BylineRepository {
10801088
"b.avatar_media_id as avatar_media_id",
10811089
"m.storage_key as avatar_storage_key",
10821090
"m.alt as avatar_alt",
1091+
"m.blurhash as avatar_blurhash",
1092+
"m.dominant_color as avatar_dominant_color",
10831093
"b.website_url as website_url",
10841094
"b.user_id as user_id",
10851095
"b.is_guest as is_guest",
@@ -1104,6 +1114,8 @@ export class BylineRepository {
11041114
avatar_media_id: row.avatar_media_id,
11051115
avatar_storage_key: row.avatar_storage_key,
11061116
avatar_alt: row.avatar_alt,
1117+
avatar_blurhash: row.avatar_blurhash,
1118+
avatar_dominant_color: row.avatar_dominant_color,
11071119
website_url: row.website_url,
11081120
user_id: row.user_id,
11091121
is_guest: row.is_guest,
@@ -1182,6 +1194,8 @@ export class BylineRepository {
11821194
"b.avatar_media_id as avatar_media_id",
11831195
"m.storage_key as avatar_storage_key",
11841196
"m.alt as avatar_alt",
1197+
"m.blurhash as avatar_blurhash",
1198+
"m.dominant_color as avatar_dominant_color",
11851199
"b.website_url as website_url",
11861200
"b.user_id as user_id",
11871201
"b.is_guest as is_guest",

packages/core/src/database/repositories/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ export interface BylineSummary {
7878
avatarStorageKey?: string | null;
7979
/** Avatar media alt text, from the same media join. Null when not joined. */
8080
avatarAlt?: string | null;
81+
/**
82+
* Avatar media blurhash (LQIP placeholder, migration 024), folded in by the
83+
* same media join as `avatarStorageKey`. Lets a renderer paint a blurred
84+
* placeholder while the full avatar loads, with no extra media lookup.
85+
* Null when the byline has no avatar, the media row has no blurhash, or the
86+
* byline was loaded through a finder that doesn't join media.
87+
*/
88+
avatarBlurhash?: string | null;
89+
/**
90+
* Avatar media dominant colour (LQIP placeholder, migration 024), from the
91+
* same media join. Null under the same conditions as `avatarBlurhash`.
92+
*/
93+
avatarDominantColor?: string | null;
8194
websiteUrl: string | null;
8295
userId: string | null;
8396
isGuest: boolean;

packages/core/tests/unit/database/repositories/byline.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ describe("BylineRepository", () => {
149149
});
150150

151151
it("hydrates avatar storage key and alt via the media join", async () => {
152-
// A media row standing in for an uploaded avatar.
152+
// A media row standing in for an uploaded avatar, including the LQIP
153+
// placeholder columns (migration 024) so the hydration carries them.
153154
await db
154155
.insertInto("media")
155156
.values({
@@ -159,6 +160,8 @@ describe("BylineRepository", () => {
159160
storage_key: "media-avatar-1.png",
160161
status: "ready",
161162
alt: "Jane Doe headshot",
163+
blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
164+
dominant_color: "#aabbcc",
162165
})
163166
.execute();
164167

@@ -189,16 +192,25 @@ describe("BylineRepository", () => {
189192
expect(avatared.byline.avatarMediaId).toBe("media-avatar-1");
190193
expect(avatared.byline.avatarStorageKey).toBe("media-avatar-1.png");
191194
expect(avatared.byline.avatarAlt).toBe("Jane Doe headshot");
192-
// No avatar -> storage key and alt are null, not undefined.
195+
// The LQIP placeholder columns ride along the same media join so a
196+
// renderer can paint a blurhash/dominant-colour placeholder while the
197+
// full avatar loads.
198+
expect(avatared.byline.avatarBlurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj");
199+
expect(avatared.byline.avatarDominantColor).toBe("#aabbcc");
200+
// No avatar -> storage key, alt, and LQIP columns are null, not undefined.
193201
expect(plain.byline.avatarStorageKey).toBeNull();
194202
expect(plain.byline.avatarAlt).toBeNull();
203+
expect(plain.byline.avatarBlurhash).toBeNull();
204+
expect(plain.byline.avatarDominantColor).toBeNull();
195205

196206
// Batch hydration path (the list-page case) resolves the same data
197207
// without a per-byline media lookup.
198208
const batch = await bylineRepo.getContentBylinesMany("post", [content.id]);
199209
const batchCredit = batch.get(content.id)!.find((c) => c.byline.id === withAvatar.id)!;
200210
expect(batchCredit.byline.avatarStorageKey).toBe("media-avatar-1.png");
201211
expect(batchCredit.byline.avatarAlt).toBe("Jane Doe headshot");
212+
expect(batchCredit.byline.avatarBlurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj");
213+
expect(batchCredit.byline.avatarDominantColor).toBe("#aabbcc");
202214
});
203215

204216
it("hydrates avatar storage key for author-inferred bylines via findByUserIds", async () => {
@@ -211,6 +223,8 @@ describe("BylineRepository", () => {
211223
storage_key: "media-avatar-3.png",
212224
status: "ready",
213225
alt: "User avatar",
226+
blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4",
227+
dominant_color: "#112233",
214228
})
215229
.execute();
216230
await db
@@ -237,6 +251,8 @@ describe("BylineRepository", () => {
237251
const resolved = map.get("user-123");
238252
expect(resolved?.avatarStorageKey).toBe("media-avatar-3.png");
239253
expect(resolved?.avatarAlt).toBe("User avatar");
254+
expect(resolved?.avatarBlurhash).toBe("L6PZfSi_.AyE_3t7t7R**0o#DgR4");
255+
expect(resolved?.avatarDominantColor).toBe("#112233");
240256
});
241257

242258
it("leaves avatar storage key null on the plain byline finders", async () => {

0 commit comments

Comments
 (0)