diff --git a/app/Actions/CreateUserBadge.php b/app/Actions/CreateUserBadge.php new file mode 100644 index 000000000..352cf4e3c --- /dev/null +++ b/app/Actions/CreateUserBadge.php @@ -0,0 +1,119 @@ +} + */ +final readonly class CreateUserBadge +{ + private const int HEIGHT = 20; + + /** + * The colors of `resources/css/app.css`. + * + * @var Palette + */ + private const array LIGHT = [ + 'card' => '#ffffff', + 'border' => '#e1e4e8', + 'ink' => '#121417', + 'muted' => '#5b616b', + 'empty' => '#eef0f2', + 'lanes' => ['#d9480f', '#2f9e44', '#9c36b5', '#0c8599', '#c2255c', '#b7791f'], + ]; + + /** + * @var Palette + */ + private const array DARK = [ + 'card' => '#18181a', + 'border' => '#2a2a2d', + 'ink' => '#ececec', + 'muted' => '#a1a1a6', + 'empty' => '#232326', + 'lanes' => ['#ff8a4c', '#51cf66', '#da77f2', '#3bc9db', '#f06595', '#fcc419'], + ]; + + /** + * Renders the compact SVG follow badge for the given user and theme. + */ + public function handle( + User $user, + ActivityGraphTheme $theme = ActivityGraphTheme::Light, + ): string { + $message = $this->message($user); + $messageWidth = $this->textWidth($message); + $dotX = 77; + $messageX = 86; + $width = $messageX + $messageWidth + 10; + + return view('svg.user-badge', [ + 'width' => $width, + 'height' => self::HEIGHT, + 'dotX' => $dotX, + 'messageX' => $messageX, + 'message' => $message, + 'styles' => $this->styles($theme, $user->lane()), + ])->render(); + } + + /** + * Formats the follow message with the user's handle. + */ + public function message(User $user): string + { + return "follow @{$user->handle}"; + } + + /** + * Calculates the estimated pixel width for the monospace message text. + */ + public function textWidth(string $text): int + { + return (int) ceil(mb_strlen($text) * 6.6); + } + + /** + * Generates CSS stylesheet rules for the given theme and user lane color. + */ + private function styles(ActivityGraphTheme $theme, int|string $lane): string + { + return match ($theme) { + ActivityGraphTheme::Light => $this->palette(self::LIGHT, $lane), + ActivityGraphTheme::Dark => $this->palette(self::DARK, $lane), + ActivityGraphTheme::Auto => $this->palette(self::LIGHT, $lane).'@media (prefers-color-scheme: dark){'.$this->palette(self::DARK, $lane).'}', + }; + } + + /** + * Compiles CSS rules mapping palette values to card, ink, muted, lane, and empty classes. + * + * @param Palette $palette + */ + private function palette(array $palette, int|string $lane): string + { + $color = is_string($lane) ? $lane : $palette['lanes'][$lane]; + + $rules = [ + '.card' => "fill:{$palette['card']};stroke:{$palette['border']}", + '.ink' => "fill:{$palette['ink']}", + '.muted' => "fill:{$palette['muted']}", + '.lane' => "fill:{$color}", + '.l0' => "fill:{$palette['empty']}", + ]; + + return implode('', array_map( + fn (string $selector, string $declarations): string => "{$selector}{{$declarations}}", + array_keys($rules), + $rules, + )); + } +} diff --git a/app/Http/Controllers/UserBadgeController.php b/app/Http/Controllers/UserBadgeController.php new file mode 100644 index 000000000..f24459291 --- /dev/null +++ b/app/Http/Controllers/UserBadgeController.php @@ -0,0 +1,23 @@ +handle($user, $request->theme()), headers: [ + 'Content-Type' => 'image/svg+xml', + ]); + } +} diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 5cb1fbda9..808fa70d3 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -28,6 +28,7 @@ public function edit(#[CurrentUser] User $user, CommitTranslations $translations 'automaticLane' => $user->automaticLane(), 'githubSyncedAt' => $user->github_synced_at?->toIso8601String(), 'activityGraphUrl' => route('user-activity-graph.show', $user), + 'badgeUrl' => route('user-badge.svg', $user), 'profileUrl' => route('user.show', $user), ]); } diff --git a/app/Http/Requests/ShowUserBadgeRequest.php b/app/Http/Requests/ShowUserBadgeRequest.php new file mode 100644 index 000000000..634ddfd38 --- /dev/null +++ b/app/Http/Requests/ShowUserBadgeRequest.php @@ -0,0 +1,36 @@ +> + */ + public function rules(): array + { + return []; + } + + /** + * Resolves the requested theme from query parameters, defaulting to light mode. + */ + public function theme(): ActivityGraphTheme + { + $theme = $this->query('theme'); + + return (is_string($theme) ? ActivityGraphTheme::tryFrom($theme) : null) ?? ActivityGraphTheme::Light; + } +} diff --git a/resources/js/components/activity-graph-embed.tsx b/resources/js/components/activity-graph-embed.tsx index 9eb1200cc..1455513d5 100644 --- a/resources/js/components/activity-graph-embed.tsx +++ b/resources/js/components/activity-graph-embed.tsx @@ -9,6 +9,7 @@ import { useUser } from '@/hooks/use-user'; import { cn } from '@/lib/utils'; type GraphTheme = 'light' | 'dark' | 'auto'; +type EmbedTab = 'graph' | 'badge'; const themes: { value: GraphTheme; icon: LucideIcon; label: string }[] = [ { value: 'light', icon: Sun, label: 'Light' }, @@ -22,19 +23,31 @@ function themedUrl(url: string, theme: GraphTheme): string { export default function ActivityGraphEmbed({ activityGraphUrl, + badgeUrl, profileUrl, }: { activityGraphUrl: string; + badgeUrl?: string; profileUrl: string; }) { const { user } = useUser(); const [, copy] = useClipboard(); + const [tab, setTab] = useState('graph'); const [theme, setTheme] = useState('light'); const [isCopied, setIsCopied] = useState(false); - const imageUrl = themedUrl(activityGraphUrl, theme); - const snippet = `[![/dev/null activity](${imageUrl})](${profileUrl})`; - const previewUrl = `${imageUrl}${theme === 'light' ? '?' : '&'}lane=${encodeURIComponent(user.lane)}`; + const resolvedBadgeUrl = + badgeUrl ?? activityGraphUrl.replace(/\/graph\.svg$/, '/badge.svg'); + + const graphImageUrl = themedUrl(activityGraphUrl, theme); + const graphSnippet = `[![/dev/null activity](${graphImageUrl})](${profileUrl})`; + const graphPreviewUrl = `${graphImageUrl}${theme === 'light' ? '?' : '&'}lane=${encodeURIComponent(user.lane)}`; + + const badgeSvgUrl = themedUrl(resolvedBadgeUrl, theme); + const badgeSnippet = `[![Follow @${user.handle} on /dev/null](${badgeSvgUrl})](${profileUrl})`; + const badgePreviewUrl = `${badgeSvgUrl}${theme === 'light' ? '?' : '&'}lane=${encodeURIComponent(user.lane)}`; + + const currentSnippet = tab === 'graph' ? graphSnippet : badgeSnippet; useEffect(() => { if (!isCopied) { @@ -47,7 +60,7 @@ export default function ActivityGraphEmbed({ }, [isCopied]); const copySnippet = async () => { - if (await copy(snippet)) { + if (await copy(currentSnippet)) { setIsCopied(true); toast( @@ -70,52 +83,119 @@ export default function ActivityGraphEmbed({ return (
- {themes.map(({ value, icon: Icon, label }) => ( - - ))} + +
-

- {theme === 'auto' - ? 'Follows the color scheme of whoever is reading.' - : `Always ${theme}, whatever the reader prefers.`} -

- -
- {`Commit + +
+ + Theme + +
+ {themes.map(({ value, icon: Icon, label }) => ( + + ))} +
+

+ {theme === 'auto' + ? 'Follows the color scheme of whoever is reading.' + : `Always ${theme}, whatever the reader prefers.`} +

+ {tab === 'graph' ? ( +
+ {`Commit +
+ ) : ( +
+
+ Preview +
+
+ {`Follow +
+
+ )} +
@@ -133,7 +213,7 @@ export default function ActivityGraphEmbed({
-                    {snippet}
+                    {currentSnippet}
                 
diff --git a/resources/js/pages/user-profile/edit.tsx b/resources/js/pages/user-profile/edit.tsx index 82a8dc161..14bdfaa96 100644 --- a/resources/js/pages/user-profile/edit.tsx +++ b/resources/js/pages/user-profile/edit.tsx @@ -29,6 +29,7 @@ export default function Edit({ automaticLane, githubSyncedAt, activityGraphUrl, + badgeUrl, profileUrl, }: { translationLanguage: string | null; @@ -37,6 +38,7 @@ export default function Edit({ automaticLane: number; githubSyncedAt: string | null; activityGraphUrl: string; + badgeUrl?: string; profileUrl: string; }) { const { user } = useUser(); @@ -128,6 +130,7 @@ export default function Edit({ diff --git a/resources/views/svg/user-badge.blade.php b/resources/views/svg/user-badge.blade.php new file mode 100644 index 000000000..ee020a0b4 --- /dev/null +++ b/resources/views/svg/user-badge.blade.php @@ -0,0 +1,9 @@ + +/dev/null: {{ $message }} + + + +/dev/null + +{{ $message }} + diff --git a/routes/web.php b/routes/web.php index 15732268d..5e837ac0a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -124,6 +124,7 @@ use App\Http\Controllers\UnsubscriptionController; use App\Http\Controllers\UserActivityGraphController; use App\Http\Controllers\UserAutomaticTranslationController; +use App\Http\Controllers\UserBadgeController; use App\Http\Controllers\UserBanController; use App\Http\Controllers\UserBlockController; use App\Http\Controllers\UserController; @@ -293,6 +294,11 @@ ->middleware(['throttle:activity-graph', 'cache.headers:public;max_age=3600;etag']) ->name('user-activity-graph.show'); +// Compact profile badge, embedded in READMEs... +Route::get('@{user:handle}/badge.svg', [UserBadgeController::class, 'show']) + ->middleware(['throttle:activity-graph', 'cache.headers:public;max_age=3600;etag']) + ->name('user-badge.svg'); + // Commit... Route::get('@{profile:handle}/commits/{commit}/og.png', [CommitOpenGraphImageController::class, 'show']) ->scopeBindings() diff --git a/tests/Feature/Controllers/UserBadgeControllerTest.php b/tests/Feature/Controllers/UserBadgeControllerTest.php new file mode 100644 index 000000000..59b7d9c74 --- /dev/null +++ b/tests/Feature/Controllers/UserBadgeControllerTest.php @@ -0,0 +1,83 @@ +create(['handle' => 'octocat']); + + $response = $this->get('/@octocat/badge.svg'); + + $response->assertOk() + ->assertHeader('Content-Type', 'image/svg+xml') + ->assertSee('assertSee('/dev/null') + ->assertSee('follow @octocat'); + + expect($response->headers->get('Cache-Control'))->toContain('public')->toContain('max-age=3600') + ->and($response->headers->get('ETag'))->not->toBeEmpty(); +}); + +it('answers not modified while the badge is unchanged', function (): void { + $user = User::factory()->create(); + + $etag = (string) $this->get(route('user-badge.svg', $user))->headers->get('ETag'); + + $this->get(route('user-badge.svg', $user), ['If-None-Match' => $etag]) + ->assertStatus(304); + + $this->get(route('user-badge.svg', $user), ['If-None-Match' => 'stale-etag']) + ->assertOk(); +}); + +it('renders follow badge message with user handle', function (): void { + $user = User::factory()->create(['handle' => 'octocat']); + + $this->get(route('user-badge.svg', $user)) + ->assertOk() + ->assertSee('follow @octocat'); +}); + +it('switches the theme and color styles', function (mixed $theme, string $expectedSelector): void { + $user = User::factory()->create(); + + $this->get(route('user-badge.svg', ['user' => $user, 'theme' => $theme])) + ->assertOk() + ->assertSee($expectedSelector, escape: false); +})->with([ + 'light' => ['light', '.card{fill:#ffffff'], + 'dark' => ['dark', '.card{fill:#18181a'], + 'auto' => ['auto', '@media (prefers-color-scheme: dark){.card{fill:#18181a'], + 'unknown' => ['synthwave', '.card{fill:#ffffff'], + 'not a string' => [['dark'], '.card{fill:#ffffff'], +]); + +it('renders user lane colors for light and dark themes', function (): void { + $user = User::factory()->create(); + + $this->get(route('user-badge.svg', ['user' => $user, 'theme' => 'light'])) + ->assertOk() + ->assertSee('.lane{fill:', escape: false); + + $this->get(route('user-badge.svg', ['user' => $user, 'theme' => 'dark'])) + ->assertOk() + ->assertSee('.lane{fill:', escape: false); +}); + +it('throttles each badge per client', function (): void { + $user = User::factory()->create(); + + foreach (range(1, 60) as $attempt) { + $this->get(route('user-badge.svg', $user))->assertOk(); + } + + $this->get(route('user-badge.svg', $user))->assertTooManyRequests(); + $this->get(route('user-badge.svg', User::factory()->create()))->assertOk(); +}); + +it('returns not found for unknown handles', function (): void { + $this->get('/@missing/badge.svg')->assertNotFound(); +}); diff --git a/tests/Feature/Controllers/UserProfileControllerTest.php b/tests/Feature/Controllers/UserProfileControllerTest.php index 5e06c7f23..f4ca797a5 100644 --- a/tests/Feature/Controllers/UserProfileControllerTest.php +++ b/tests/Feature/Controllers/UserProfileControllerTest.php @@ -53,6 +53,7 @@ $response->assertOk() ->assertInertia(fn ($page) => $page ->where('activityGraphUrl', url('/@octocat/graph.svg')) + ->where('badgeUrl', url('/@octocat/badge.svg')) ->where('profileUrl', url('/@octocat')) ->where('auth.user.timezone', $timezone)); })->with([null, 'Europe/Budapest']); diff --git a/tests/Unit/Actions/CreateUserBadgeTest.php b/tests/Unit/Actions/CreateUserBadgeTest.php new file mode 100644 index 000000000..675a5dc1b --- /dev/null +++ b/tests/Unit/Actions/CreateUserBadgeTest.php @@ -0,0 +1,55 @@ +loadXML($svg))->toBeTrue(); + + $xpath = new DOMXPath($document); + $xpath->registerNamespace('svg', 'http://www.w3.org/2000/svg'); + + return $xpath; +} + +it('generates a valid XML SVG follow badge with branding and handle', function (): void { + $user = User::factory()->create(['handle' => 'octocat']); + + $action = resolve(CreateUserBadge::class); + $svg = $action->handle($user, ActivityGraphTheme::Light); + + $xpath = userBadgeXpath($svg); + + $nodes = $xpath->query('//svg:text'); + expect($nodes)->not->toBeFalse() + ->and($nodes->length)->toBeGreaterThan(0); + + $text = $xpath->evaluate('normalize-space(/svg:svg)'); + assert(is_string($text)); + expect($text)->toContain('/dev/null') + ->toContain('follow @octocat'); +}); + +it('formats follow message with user handle', function (): void { + $user = User::factory()->create(['handle' => 'alice']); + + $action = resolve(CreateUserBadge::class); + expect($action->message($user))->toBe('follow @alice'); +}); + +it('calculates text width based on length', function (): void { + $action = resolve(CreateUserBadge::class); + + expect($action->textWidth('a'))->toBeLessThan($action->textWidth('abc')) + ->and($action->textWidth('follow @octocat'))->toBeGreaterThan(0); +}); diff --git a/tests/Unit/ActivityGraphEmbed.test.tsx b/tests/Unit/ActivityGraphEmbed.test.tsx new file mode 100644 index 000000000..7cbd715bf --- /dev/null +++ b/tests/Unit/ActivityGraphEmbed.test.tsx @@ -0,0 +1,73 @@ +/// + +import assert from 'node:assert/strict'; +import { createInertiaApp } from '@inertiajs/react'; +import { renderToString } from 'react-dom/server'; +import { test } from 'vite-plus/test'; +import ActivityGraphEmbed from '../../resources/js/components/activity-graph-embed'; +import type { User } from '../../resources/js/types'; + +async function renderEmbed( + props: { + activityGraphUrl: string; + badgeUrl?: string; + profileUrl: string; + }, + user: Partial = {}, +): Promise { + const defaultUser: User = { + id: 'user-1', + name: 'Octo Cat', + handle: 'octocat', + avatar_url: 'https://example.com/avatar.png', + lane: 0, + ...user, + } as User; + + const result = await createInertiaApp<{ auth: { user: User } }>({ + page: { + component: 'embed', + props: { auth: { user: defaultUser }, errors: {} }, + url: '/settings/profile', + version: null, + clearHistory: false, + encryptHistory: false, + rescuedProps: [], + rememberedState: {}, + flash: {}, + }, + resolve: () => () => , + render: renderToString, + setup: ({ App, props }) => , + }); + + assert.ok(result); + + return result.body; +} + +test('renders activity graph embed section and tabs by default', async () => { + const html = await renderEmbed({ + activityGraphUrl: 'https://dev-null.sh/@octocat/graph.svg', + badgeUrl: 'https://dev-null.sh/@octocat/badge.svg', + profileUrl: 'https://dev-null.sh/@octocat', + }); + + assert.ok(html.includes('data-test="activity-graph-embed"')); + assert.ok(html.includes('data-test="graph-embed-tab"')); + assert.ok(html.includes('data-test="badge-embed-tab"')); + assert.ok(html.includes('data-test="activity-graph-preview"')); + assert.ok(html.includes('data-test="embed-snippet"')); + assert.ok(html.includes('https://dev-null.sh/@octocat/graph.svg')); + assert.ok(html.includes('https://dev-null.sh/@octocat')); +}); + +test('falls back to replacing graph.svg with badge.svg when badgeUrl is not provided', async () => { + const html = await renderEmbed({ + activityGraphUrl: 'https://dev-null.sh/@octocat/graph.svg', + profileUrl: 'https://dev-null.sh/@octocat', + }); + + assert.ok(html.includes('data-test="activity-graph-embed"')); + assert.ok(html.includes('Follow badge')); +});