audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: show "liked N ago" in liker stack avatar tooltips (#1304)

Previous cut lost timestamp info — hovering an avatar showed only
display name. Adding it back without reintroducing a separate panel:

- backend LikerPreview now includes `liked_at` (ISO string) pulled
from `track_likes.created_at` via the window-function query
- AvatarStack gets an optional `avatarTitle(user)` prop so parents
can customize the hover/focus tooltip
- LikersStrip passes a formatter that renders
"display name · liked 2h ago"

UserPreview.liked_at is optional — supporter avatars on the artist
page don't carry a timestamp and keep their existing display-name
tooltip.

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
d94da7f3 072f9061

+41 -8
+7 -3
backend/src/backend/utilities/aggregations.py
··· 36 36 """lightweight liker preview embedded in track responses. 37 37 38 38 used to render the overlapping avatar stack next to a track's like count 39 - without a follow-up request. the full `LikerInfo` (with `liked_at`) is 40 - still served by `GET /tracks/{id}/likes` for the detail tooltip/sheet. 39 + without a follow-up request. includes `liked_at` so the per-avatar hover 40 + tooltip can show "display name · 2h ago" without a follow-up fetch. 41 41 42 42 defined here (alongside aggregation helpers) rather than in `schemas.py` 43 43 to avoid a circular import: `schemas.py` imports from `aggregations.py`. ··· 47 47 handle: str 48 48 display_name: str | None 49 49 avatar_url: str | None 50 + liked_at: str 50 51 51 52 52 53 async def get_like_counts(db: AsyncSession, track_ids: list[int]) -> dict[int, int]: ··· 114 115 Artist.display_name.label("display_name"), 115 116 Artist.avatar_url.label("avatar_url"), 116 117 TrackLike.track_id.label("track_id"), 118 + TrackLike.created_at.label("liked_at"), 117 119 rn, 118 120 ) 119 121 .join(Artist, Artist.did == TrackLike.user_did) ··· 128 130 ranked.c.display_name, 129 131 ranked.c.avatar_url, 130 132 ranked.c.track_id, 133 + ranked.c.liked_at, 131 134 ) 132 135 .where(ranked.c.rn <= limit) 133 136 .order_by(ranked.c.track_id, ranked.c.rn) ··· 135 138 136 139 result = await db.execute(stmt) 137 140 out: dict[int, list[LikerPreview]] = defaultdict(list) 138 - for did, handle, display_name, avatar_url, track_id in result.all(): 141 + for did, handle, display_name, avatar_url, track_id, liked_at in result.all(): 139 142 out[track_id].append( 140 143 LikerPreview( 141 144 did=did, 142 145 handle=handle, 143 146 display_name=display_name, 144 147 avatar_url=avatar_url, 148 + liked_at=liked_at.isoformat(), 145 149 ) 146 150 ) 147 151 return dict(out)
+5 -4
backend/tests/utilities/test_aggregations.py
··· 325 325 async def test_get_top_likers_preview_fields( 326 326 db_session: AsyncSession, test_tracks_with_liker_artists: list[Track] 327 327 ): 328 - """LikerPreview has did, handle, display_name, avatar_url — and no liked_at.""" 328 + """LikerPreview has did, handle, display_name, avatar_url, liked_at.""" 329 329 tracks = test_tracks_with_liker_artists 330 330 result = await get_top_likers(db_session, [tracks[0].id]) 331 331 ··· 334 334 assert liker.handle.endswith(".bsky.social") 335 335 assert liker.display_name is not None 336 336 assert liker.avatar_url is not None 337 - # LikerPreview is deliberately lighter than the tooltip's LikerInfo — no 338 - # liked_at field, since the inline stack doesn't render timestamps 339 - assert not hasattr(liker, "liked_at") 337 + # liked_at is included so the hover tooltip can show "name · 2h ago" 338 + # without a follow-up fetch 339 + assert liker.liked_at # ISO-formatted timestamp string 340 + assert "T" in liker.liked_at # crude format check 340 341 341 342 342 343 async def test_get_top_likers_respects_custom_limit(
+11 -1
frontend/src/lib/components/AvatarStack.svelte
··· 24 24 avatarHref?: (u: UserPreview) => string; 25 25 /** handler for clicks on individual avatars (e.g. stop propagation). */ 26 26 onAvatarClick?: (u: UserPreview, e: MouseEvent) => void; 27 + /** build the hover/focus title shown for each avatar. defaults to 28 + * `display_name || handle`. likers surfaces pass a formatter that 29 + * appends the relative `liked_at` timestamp. */ 30 + avatarTitle?: (u: UserPreview) => string; 27 31 /** accessible label for the strip (e.g. "21 likes"). */ 28 32 ariaLabel?: string; 29 33 /** extra class on the container, for site-specific tweaks. */ ··· 48 52 onMoreClick, 49 53 avatarHref, 50 54 onAvatarClick, 55 + avatarTitle, 51 56 ariaLabel, 52 57 class: klass = '', 53 58 scrollable = false, 54 59 maxScrollWidth = '20rem' 55 60 }: Props = $props(); 56 61 62 + function resolveTitle(u: UserPreview): string { 63 + if (avatarTitle) return avatarTitle(u); 64 + return u.display_name || u.handle; 65 + } 66 + 57 67 let visible = $derived(users.slice(0, maxVisible)); 58 68 let overflow = $derived(Math.max(0, total - visible.length)); 59 69 // overlap grows with size so the visual density stays consistent. ··· 79 89 style="--stack-size: {size}px; --stack-overlap: {overlap}px; --stack-border: {borderColor}; --stack-max-width: {maxScrollWidth};" 80 90 > 81 91 {#each visible as user (user.did)} 82 - {@const title = user.display_name || user.handle} 92 + {@const title = resolveTitle(user)} 83 93 {#if avatarHref} 84 94 <a 85 95 class="avatar"
+15
frontend/src/lib/components/LikersStrip.svelte
··· 94 94 }); 95 95 96 96 let likeWord = $derived(likeCount === 1 ? 'like' : 'likes'); 97 + 98 + function formatRelativeTime(iso: string): string { 99 + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); 100 + if (seconds < 60) return 'just now'; 101 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 102 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 103 + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`; 104 + return `${Math.floor(seconds / 604800)}w ago`; 105 + } 106 + 107 + function avatarTitle(u: UserPreview): string { 108 + const name = u.display_name || u.handle; 109 + return u.liked_at ? `${name} · liked ${formatRelativeTime(u.liked_at)}` : name; 110 + } 97 111 </script> 98 112 99 113 <span ··· 113 127 scrollable={expanded} 114 128 onMoreClick={expanded ? undefined : handleMoreClick} 115 129 avatarHref={(u) => `/u/${u.handle}`} 130 + {avatarTitle} 116 131 ariaLabel={`${likeCount} ${likeWord}`} 117 132 /> 118 133 {#if expanded}
+3
frontend/src/lib/types.ts
··· 42 42 handle: string; 43 43 display_name?: string | null; 44 44 avatar_url?: string | null; 45 + /** ISO timestamp — only set for likers (from backend LikerPreview). 46 + * supporters from atprotofans don't carry a timestamp. */ 47 + liked_at?: string; 45 48 } 46 49 47 50 export interface Track {