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
2 changes: 2 additions & 0 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ URLs and bare domains in commits and replies become clickable links. Long URLs a

Supported links can also show click-to-play embedded players: YouTube, Vimeo, Loom, Twitch, Spotify, SoundCloud, Apple Music, Suno, CodePen, and asciinema. Only the link that gets the card can show a player, so choosing no card means no player. Embeds depend on server configuration and the linked URL being supported. Third-party player content loads only after you press play; those providers have their own privacy policies. This is embedding a link, not uploading a video or audio file to /dev/null.

Giphy share links and verified public HTTPS `.gif` links play inline from their external host. Verified GIFs autoplay while visible and unload offscreen; reduced-motion users press **Play**. **Stop** unloads the animation, and failed GIFs offer two manual retries. No uploads or provider keys are required. Enable with `EMBEDS_PROVIDERS=giphy,gif`. Previously cached direct GIF links require refetching.

Sources: [link rendering](resources/js/components/linkified-text.tsx), [link-card selection](app/Http/Requests/CreateCommitRequest.php), [embed configuration](config/embeds.php), [embedded player](resources/js/components/link-embed.tsx).

### Replies and threads
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ composer setup
composer dev
```

Giphy links and verified public HTTPS `.gif` links play inline from their external host, with Play/Stop controls, visible-only autoplay, and manual playback for reduced motion.

Run `composer test` before opening a pull request.

## License
Expand Down
7 changes: 5 additions & 2 deletions app/Dto/Embed.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ private function __construct(
}

/**
* The poster stands in for the player until it is played, for when the link's own preview has no image.
*
* @param array<string, string|int> $query
*/
public static function video(string $provider, string $src, array $query = [], string $aspectRatio = '16 / 9', ?string $poster = null): self
Expand All @@ -41,6 +39,11 @@ public static function fixed(string $provider, EmbedKind $kind, string $src, int
return new self($provider, $kind, self::withQuery($src, $query), null, $height);
}

public static function gif(string $provider, string $src, ?string $poster = null): self
{
return new self($provider, EmbedKind::Gif, $src, null, null, $poster);
}

/**
* @return array{provider: string, kind: string, src: string, aspect_ratio: string|null, height: int|null, poster: string|null}
*/
Expand Down
1 change: 1 addition & 0 deletions app/Enums/EmbedKind.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ enum EmbedKind: string
case Video = 'video';
case Audio = 'audio';
case Code = 'code';
case Gif = 'gif';
}
2 changes: 1 addition & 1 deletion app/Models/LinkPreview.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,6 @@ public function casts(): array
*/
public function toArray(): array
{
return [...parent::toArray(), 'is_pending' => $this->fetched_at === null, 'embed' => resolve(Embeds::class)->for($this->url)?->toArray()];
return [...parent::toArray(), 'is_pending' => $this->fetched_at === null, 'embed' => resolve(Embeds::class)->for($this->url, $this->gif_url)?->toArray()];
}
}
75 changes: 75 additions & 0 deletions app/Services/EmbedProviders/Giphy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace App\Services\EmbedProviders;

use App\Contracts\Services\EmbedProvider;
use App\Dto\Embed;
use Illuminate\Support\Str;
use Uri\WhatWg\Url;

final readonly class Giphy implements EmbedProvider
{
private const array SUPPORTED_SCHEMES = ['http', 'https'];

private const array SUPPORTED_HOSTS = ['giphy.com', 'www.giphy.com'];

private const int PATH_SEGMENT_COUNT = 2;

public function embed(Url $url): ?Embed
{
if (! $this->supportsUrl($url)) {
return null;
}

$identifier = $this->getGifId($url->getPath());

if ($identifier === null) {
return null;
}

$mediaUrl = "https://media.giphy.com/media/{$identifier}";

return Embed::gif('GIPHY', "{$mediaUrl}/giphy.gif", "{$mediaUrl}/giphy_s.gif");
}

private function supportsUrl(Url $url): bool
{
if (! in_array($url->getScheme(), self::SUPPORTED_SCHEMES, true)) {
return false;
}

if (! in_array($url->getAsciiHost(), self::SUPPORTED_HOSTS, true)) {
return false;
}

if (($url->getUsername() ?? '') !== '') {
return false;
}

if (($url->getPassword() ?? '') !== '') {
return false;
}

return $url->getPort() === null;
}

private function getGifId(string $path): ?string
{
$segments = explode('/', mb_trim($path, '/'));

if (count($segments) !== self::PATH_SEGMENT_COUNT) {
return null;
}

[$section, $slug] = $segments;
$identifier = match (Str::lower($section)) {
'gifs' => Str::afterLast($slug, '-'),
'embed' => $slug,
default => '',
};

return ctype_alnum($identifier) ? $identifier : null;
}
}
12 changes: 10 additions & 2 deletions app/Services/Embeds.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@

final readonly class Embeds
{
private const string GIF_PROVIDER = 'GIF';

public function __construct(private Container $container)
{
//
}

public function for(string $url): ?Embed
public function for(string $url, ?string $gifUrl = null): ?Embed
{
$parsed = Url::parse($url);

Expand All @@ -32,7 +34,13 @@ public function for(string $url): ?Embed
}
}

return null;
$only = config()->array('embeds.only');

if ($gifUrl === null || ($only !== [] && ! in_array('gif', $only, true))) {
return null;
}

return Embed::gif(self::GIF_PROVIDER, $gifUrl);
}

/**
Expand Down
3 changes: 3 additions & 0 deletions config/embeds.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Services\EmbedProviders\AppleMusic;
use App\Services\EmbedProviders\Asciinema;
use App\Services\EmbedProviders\CodePen;
use App\Services\EmbedProviders\Giphy;
use App\Services\EmbedProviders\Loom;
use App\Services\EmbedProviders\SoundCloud;
use App\Services\EmbedProviders\Spotify;
Expand All @@ -17,9 +18,11 @@

'enabled' => (bool) env('EMBEDS_ENABLED', true),

// "gif" selects verified external GIFs; the other keys select providers below.
'only' => array_values(array_filter(array_map(trim(...), explode(',', (string) env('EMBEDS_PROVIDERS', ''))))),

'providers' => [
'giphy' => Giphy::class,
'youtube' => YouTube::class,
'vimeo' => Vimeo::class,
'loom' => Loom::class,
Expand Down
Loading