Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ Share a stream you are running from the **…** menu on your own profile: **Go l

Sources: [go-live button](resources/js/components/profile-live-button.tsx), [live avatar and card](resources/js/components/live-stream-card.tsx), [validation](app/Http/Requests/UpdateUserLiveRequest.php), [YouTube links](app/Services/LivePlatforms/YouTube.php), [Twitch links](app/Services/LivePlatforms/Twitch.php), [ending streams](app/Jobs/EndLiveStreams.php).

### Winamp

On a computer, signed in, search for **winamp** (or **webamp**, or **llama**) and choose **Open Winamp** to open a Winamp player, built on Webamp, that keeps playing while you move between pages. It starts with a short playlist of lo-fi beats from Audius, and nothing plays until you press play. Closing it with its own close button stops the music; opening it again brings the same player back. Phones and tablets don't offer it.

Share a playlist of your own in profile settings, under **Winamp playlist**: paste a link to a public playlist or album on Audius (`https://audius.co/…/playlist/…` or `https://audius.co/…/album/…`). Your profile then shows a music-note button on computers that loads your playlist into Winamp and starts it, for anyone looking at it, signed in or not. Only the first 200 tracks Winamp can play are loaded. Tracks Audius doesn't let everyone stream, such as ones only for an artist's supporters, are left out. The playlist is read from Audius at most every ten minutes, so changes you make there can take that long to show. **Remove** stops sharing it. Only the link is stored, and it is included in your export.

The music comes from Audius because Winamp needs the audio itself to draw its visualizer and run its equalizer, and Audius lets other sites play its tracks. Spotify, YouTube, and Apple Music only play inside their own players, so their music can't play in Winamp. Once you open Winamp, the audio loads straight from Audius to your browser, under Audius's own privacy policy.

Sources: [player](resources/js/lib/winamp.ts), [profile button](resources/js/components/profile-winamp-button.tsx), [playlist setting](resources/js/components/playlist-setting.tsx), [Audius playlists](app/Services/PlaylistProviders/AudiusPlaylistProvider.php).

### Achievement badges and XP

Profiles have **16 achievement badges**. Earned badges appear in a single row, with **+N more** when more have been earned than fit. Hover over the row on desktop or tap it on mobile to toggle a popover with every badge, including locked badges and their progress. Profiles with no earned badges show **View achievements** instead. Select a badge in the popover, or the profile's rank or unlocked count, to open the detailed achievements dialog.
Expand Down
1 change: 1 addition & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ What exists, one line each (details in the knowledge base):
- **Logs:** Following, Latest, and Trending.
- **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries).
- **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube.
- **Winamp:** a Winamp player on computers, opened from search, playing music from Audius; members can share an Audius playlist that visitors play from their profile.
- **Organization pages:** GitHub organizations get a page mirrored from GitHub once an approved commit mentions them, and `@organization` mentions link to it. Their members post commits as the organization, which readers see from the organization alone.
- **Remotes:** private one-to-one and group messages (orgs), and calls, one-to-one or in an org.
- **Notifications:** in-app activity and on-site Remotes alerts by default; off-site browser and PWA notifications require a separate opt-in. Sound is controlled independently and plays while the site is open.
Expand Down
1 change: 1 addition & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ public function handle(User $user): array
'live_platform' => $user->live_platform?->value,
'live_url' => $user->live_url,
'live_ends_at' => $user->live_ends_at?->toIso8601String(),
'playlist_url' => $user->playlist_url,
'last_seen_at' => $user->last_seen_at?->toDateString(),
'commit_rules_accepted_at' => $user->commit_rules_accepted_at?->toIso8601String(),
'emails_unsubscribed_at' => $user->emails_unsubscribed_at?->toIso8601String(),
Expand Down
21 changes: 21 additions & 0 deletions app/Actions/DeleteUserPlaylist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\UserUpdate;
use App\Events\ProfilesUpdated;
use App\Events\UsersUpdated;
use App\Models\User;

final readonly class DeleteUserPlaylist
{
public function handle(User $user): void
{
$user->update(['playlist_url' => null]);

event(new ProfilesUpdated([$user->id]));
event(new UsersUpdated([$user->id], UserUpdate::Account));
}
}
21 changes: 21 additions & 0 deletions app/Actions/UpdateUserPlaylist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\UserUpdate;
use App\Events\ProfilesUpdated;
use App\Events\UsersUpdated;
use App\Models\User;

final readonly class UpdateUserPlaylist
{
public function handle(User $user, string $url): void
{
$user->update(['playlist_url' => $url]);

event(new ProfilesUpdated([$user->id]));
event(new UsersUpdated([$user->id], UserUpdate::Account));
}
}
20 changes: 20 additions & 0 deletions app/Contracts/Services/PlaylistProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace App\Contracts\Services;

use Illuminate\Http\Client\HttpClientException;

/**
* @phpstan-type PlaylistTrack array{artist: string, title: string, duration: int, url: string}
*/
interface PlaylistProvider
{
/**
* @return list<PlaylistTrack>|null
*
* @throws HttpClientException
*/
public function tracks(string $url): ?array;
}
5 changes: 4 additions & 1 deletion app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use App\Queries\FollowersYouKnowQuery;
use App\Queries\GitHubReadmeQuery;
use App\Queries\PinnedCommitQuery;
use App\Queries\PlaylistQuery;
use App\Queries\PushAccessQuery;
use App\Queries\UserAchievementsQuery;
use App\Queries\UserActivityQuery;
Expand All @@ -38,7 +39,7 @@
/**
* Profiles are public, so guests and crawlers read the same page, with the profile's link preview.
*/
public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, BioProfilesQuery $bioProfiles, PageMetadata $metadata): Response
public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, BioProfilesQuery $bioProfiles, PlaylistQuery $playlist, PageMetadata $metadata): Response
{
$blockerId = $currentUser?->blockerIdWith($user);

Expand Down Expand Up @@ -67,6 +68,8 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser
return Inertia::render('user/show', [
'user' => Inertia::live(fn (): ?array => ShownStreams::user($getCountedUser()), 'profile', $profile),
'bioProfiles' => fn (): array => $bioProfiles->get($user),
'hasPlaylist' => Inertia::live(fn (): bool => $user->playlist_url !== null, 'profile', $profile),
'playlist' => Inertia::optional(fn (): array => $playlist->get($user)),
'pasteisReceived' => Inertia::live(fn (): ?int => $currentUser?->pasteis_mode ? $user->pasteis_received : null, 'profile', $profile),
'isFollowing' => $relationship(fn (): bool => $currentUser?->isFollowing($user) ?? false),
'followsYou' => $relationship(fn (): bool => $currentUser instanceof User && $user->isFollowing($currentUser)),
Expand Down
29 changes: 29 additions & 0 deletions app/Http/Controllers/UserPlaylistController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\DeleteUserPlaylist;
use App\Actions\UpdateUserPlaylist;
use App\Http\Requests\UpdateUserPlaylistRequest;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\RedirectResponse;

final readonly class UserPlaylistController
{
public function update(UpdateUserPlaylistRequest $request, #[CurrentUser] User $user, UpdateUserPlaylist $action): RedirectResponse
{
$action->handle($user, $request->playlistUrl());

return back();
}

public function destroy(#[CurrentUser] User $user, DeleteUserPlaylist $action): RedirectResponse
{
$action->handle($user);

return back();
}
}
1 change: 1 addition & 0 deletions app/Http/Controllers/UserProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public function edit(#[CurrentUser] User $user, CommitTranslations $translations
'githubSyncedAt' => $user->github_synced_at?->toIso8601String(),
'activityGraphUrl' => route('user-activity-graph.show', $user),
'profileUrl' => route('user.show', $user),
'playlistUrl' => $user->playlist_url,
]);
}

Expand Down
32 changes: 32 additions & 0 deletions app/Http/Requests/UpdateUserPlaylistRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Rules\PlaylistUrl;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

final class UpdateUserPlaylistRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* @return array<string, list<string|ValidationRule>>
*/
public function rules(): array
{
return [
'playlist_url' => ['bail', 'required', 'string', 'max:255', new PlaylistUrl()],
];
}

public function playlistUrl(): string
{
return $this->string('playlist_url')->value();
}
}
4 changes: 4 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
* @property-read LivePlatform|null $live_platform
* @property-read string|null $live_url
* @property-read CarbonInterface|null $live_ends_at
* @property-read string|null $playlist_url
* @property-read Log $log
* @property-read CarbonInterface|null $onboarded_at
* @property-read CarbonInterface|null $github_follows_imported_at
Expand Down Expand Up @@ -125,6 +126,7 @@
'live_platform',
'live_url',
'live_ends_at',
'playlist_url',
'last_seen_at',
'banned_at',
'commit_rules_accepted_at',
Expand Down Expand Up @@ -179,6 +181,7 @@ final class User extends Authenticatable
'live_platform' => null,
'live_url' => null,
'live_ends_at' => null,
'playlist_url' => null,
'last_seen_at' => null,
'banned_at' => null,
'commit_rules_accepted_at' => null,
Expand Down Expand Up @@ -232,6 +235,7 @@ public function casts(): array
'live_platform' => LivePlatform::class,
'live_url' => 'string',
'live_ends_at' => 'datetime',
'playlist_url' => 'string',
'last_seen_at' => 'date',
'banned_at' => 'datetime',
'commit_rules_accepted_at' => 'datetime',
Expand Down
7 changes: 7 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use App\Contracts\Services\HeadlineProvider;
use App\Contracts\Services\LinkPreviewProvider;
use App\Contracts\Services\ModerationProvider;
use App\Contracts\Services\PlaylistProvider;
use App\Contracts\Services\PushNotificationProvider;
use App\Contracts\Services\RelevanceProvider;
use App\Contracts\Services\ToolDecisionProvider;
Expand All @@ -34,6 +35,8 @@
use App\Services\LinkPreviewProviders\NullLinkPreviewProvider;
use App\Services\ModerationProviders\NullModerationProvider;
use App\Services\ModerationProviders\OpenAIModerationProvider;
use App\Services\PlaylistProviders\AudiusPlaylistProvider;
use App\Services\PlaylistProviders\NullPlaylistProvider;
use App\Services\PushNotificationProviders\NullPushNotificationProvider;
use App\Services\PushNotificationProviders\WebPushNotificationProvider;
use App\Services\RelevanceProviders\NullRelevanceProvider;
Expand Down Expand Up @@ -70,6 +73,7 @@ final class AppServiceProvider extends ServiceProvider
HeadlineProvider::class => OpenAIHeadlineProvider::class,
LinkPreviewProvider::class => HttpLinkPreviewProvider::class,
ModerationProvider::class => OpenAIModerationProvider::class,
PlaylistProvider::class => AudiusPlaylistProvider::class,
PushNotificationProvider::class => WebPushNotificationProvider::class,
RelevanceProvider::class => OpenAIRelevanceProvider::class,
ToolDecisionProvider::class => OpenAIToolDecisionProvider::class,
Expand All @@ -90,6 +94,7 @@ final class AppServiceProvider extends ServiceProvider
HeadlineProvider::class => NullHeadlineProvider::class,
LinkPreviewProvider::class => NullLinkPreviewProvider::class,
ModerationProvider::class => NullModerationProvider::class,
PlaylistProvider::class => NullPlaylistProvider::class,
PushNotificationProvider::class => NullPushNotificationProvider::class,
RelevanceProvider::class => NullRelevanceProvider::class,
ToolDecisionProvider::class => NullToolDecisionProvider::class,
Expand Down Expand Up @@ -240,6 +245,8 @@ private function bootRateLimitingDefaults(): void
$request->user() instanceof User ? $this->userId($request) : 'ip:'.$request->ip(),
));

RateLimiter::for('playlist-links', fn (Request $request): Limit => Limit::perMinute(10)->by($this->userId($request)));

// Every issue lookup GitHub hasn't answered in the last five minutes costs a request out of the shared hourly budget,
// which also keeps SSH keys in sync for the CLI, so it gets a budget of its own...
RateLimiter::for('issue-suggestions', fn (Request $request): array => [
Expand Down
47 changes: 47 additions & 0 deletions app/Queries/PlaylistQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace App\Queries;

use App\Contracts\Services\PlaylistProvider;
use App\Models\User;
use Illuminate\Http\Client\HttpClientException;
use Illuminate\Support\Facades\Cache;

/**
* @phpstan-import-type PlaylistTrack from PlaylistProvider
*/
final readonly class PlaylistQuery
{
private const int TTL = 600;

private const int FAILURE_TTL = 60;

public function __construct(private PlaylistProvider $playlists)
{
//
}

/**
* @return list<PlaylistTrack>
*/
public function get(User $user): array
{
$url = $user->playlist_url;

if ($url === null) {
return [];
}

$key = 'playlist:'.hash('xxh128', $url);

try {
return Cache::remember($key, self::TTL, fn (): array => $this->playlists->tracks($url) ?? []);
} catch (HttpClientException) {
Cache::put($key, [], self::FAILURE_TTL);

return [];
}
}
}
28 changes: 28 additions & 0 deletions app/Rules/PlaylistUrl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Rules;

use App\Contracts\Services\PlaylistProvider;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\Client\HttpClientException;

final readonly class PlaylistUrl implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$tracks = is_string($value) ? resolve(PlaylistProvider::class)->tracks($value) : null;
} catch (HttpClientException) {
$fail("Audius isn't answering. Try again in a moment.");

return;
}

if ($tracks === null) {
$fail('Use a link to a public playlist or album on Audius.');
}
}
}
45 changes: 45 additions & 0 deletions app/Services/PlaylistProviders/AudiusPlaylistProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace App\Services\PlaylistProviders;

use App\Contracts\Services\PlaylistProvider;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Fluent;

final readonly class AudiusPlaylistProvider implements PlaylistProvider
{
private const string APP_NAME = 'devnull';

private const int MOST_TRACKS = 200;

public function tracks(string $url): ?array
{
if (preg_match('~\Ahttps://(?:www\.)?audius\.co/[^/?#]+/(?:playlist|album)/[^/?#]+\z~', $url) !== 1) {
return null;
}

$response = Http::acceptJson()->connectTimeout(3)->timeout(5)
->get('https://api.audius.co/v1/resolve', ['url' => $url, 'app_name' => self::APP_NAME]);

if ($response->notFound() || $response->throw()->json('data.0.is_private') === true) {
return null;
}

return array_values($response->collect('data.0.tracks')
->map(fn (mixed $track): Fluent => new Fluent(is_array($track) ? $track : []))
->filter(fn (Fluent $track): bool => $track->boolean('access.stream')
&& ! $track->boolean('is_delete')
&& $track->string('id')->isNotEmpty()
&& $track->string('title')->trim()->isNotEmpty())
->take(self::MOST_TRACKS)
->map(fn (Fluent $track): array => [
'artist' => $track->string('user.name')->trim()->value(),
'title' => $track->string('title')->trim()->value(),
'duration' => $track->integer('duration'),
'url' => 'https://api.audius.co/v1/tracks/'.rawurlencode($track->string('id')->value()).'/stream?app_name='.self::APP_NAME,
])
->all());
}
}
Loading