-
-
Notifications
You must be signed in to change notification settings - Fork 332
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #715 from CamKem/feat/link-previews
Feat: Link Previews (OG Images & Video + oEmbed Videos & Tweets)
- Loading branch information
Showing
12 changed files
with
450 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace App\Services; | ||
|
||
use DOMDocument; | ||
use Illuminate\Http\Client\ConnectionException; | ||
use Illuminate\Support\Collection; | ||
use Illuminate\Support\Facades\Cache; | ||
use Illuminate\Support\Facades\Http; | ||
use Illuminate\Support\Str; | ||
|
||
final readonly class MetaData | ||
{ | ||
/** | ||
* Fetch the Open Graph data for a given URL. | ||
*/ | ||
public function __construct(private string $url) | ||
{ | ||
// | ||
} | ||
|
||
/** | ||
* Fetch the parsed meta-data for the URL | ||
* | ||
* @return Collection<string, string> | ||
*/ | ||
public function fetch(): Collection | ||
{ | ||
/** @var Collection<string, string> $cachedData */ | ||
$cachedData = Cache::remember( | ||
Str::of($this->url)->slug()->prepend('preview_')->value(), | ||
now()->addYear(), | ||
fn (): Collection => $this->getData() | ||
); | ||
|
||
return $cachedData; | ||
} | ||
|
||
/** | ||
* Get the meta-data for a given URL. | ||
* | ||
* @return Collection<string, string> | ||
*/ | ||
private function getData(): Collection | ||
{ | ||
$data = collect(); | ||
|
||
try { | ||
$response = Http::get($this->url); | ||
|
||
if ($response->ok()) { | ||
$data = $this->parse($response->body()) | ||
->filter(fn ($value): bool => $value !== ''); | ||
} | ||
} catch (ConnectionException) { | ||
// Catch but not capture the exception | ||
} | ||
|
||
return $data; | ||
} | ||
|
||
/** | ||
* Fetch the oEmbed data for a given URL. | ||
* | ||
* @param array<string, string> $options | ||
* @return Collection<string, string> | ||
*/ | ||
private function fetchOEmbed(string $service, array $options): Collection | ||
{ | ||
$data = collect(); | ||
|
||
try { | ||
$response = Http::get( | ||
url: $service.'?url='.urlencode($this->url).'&'.http_build_query($options) | ||
); | ||
|
||
if ($response->ok()) { | ||
$data = collect((array) $response->json()) | ||
->filter(fn ($value): bool => $value !== ''); | ||
} | ||
} catch (ConnectionException) { | ||
// Catch but not capture the exception | ||
} | ||
|
||
return $data; | ||
} | ||
|
||
/** | ||
* Parse the response body for MetaData. | ||
* | ||
* @return Collection<string, string> | ||
*/ | ||
private function parse(string $content): Collection | ||
{ | ||
$doc = new DOMDocument(); | ||
@$doc->loadHTML($content); | ||
|
||
$interested_in = ['og', 'twitter']; | ||
$allowed = ['title', 'description', 'keywords', 'image', 'site_name', 'url', 'type']; | ||
$data = collect(); | ||
$metas = $doc->getElementsByTagName('meta'); | ||
|
||
if ($metas->count() > 0) { | ||
foreach ($metas as $meta) { | ||
if (mb_strtolower($meta->getAttribute('name')) === 'title') { | ||
$data->put('title', $meta->getAttribute('content')); | ||
} | ||
|
||
if (mb_strtolower($meta->getAttribute('name')) === 'description') { | ||
$data->put('description', $meta->getAttribute('content')); | ||
} | ||
if (mb_strtolower($meta->getAttribute('name')) === 'keywords') { | ||
$data->put('keywords', $meta->getAttribute('content')); | ||
} | ||
|
||
collect(['name', 'property']) | ||
->map(fn ($name): string => $meta->getAttribute($name)) | ||
->filter(fn ($attribute): bool => in_array(explode(':', $attribute)[0], $interested_in)) | ||
->each(function ($attribute) use ($data, $allowed, $meta): void { | ||
$key = explode(':', $attribute)[1]; | ||
if (! $data->has($key) && in_array($key, $allowed, true)) { | ||
$data->put($key, $meta->getAttribute('content')); | ||
} | ||
}); | ||
} | ||
} | ||
|
||
if ($data->has('site_name') && $data->get('site_name') === 'X (formerly Twitter)') { | ||
$x = $this->fetchOEmbed( | ||
service: 'https://publish.twitter.com/oembed', | ||
options: [ | ||
'dnt' => 'true', | ||
'omit_script' => 'true', | ||
'hide_thread' => 'true', | ||
'maxwidth' => '446', | ||
'maxheight' => '251', | ||
]); | ||
if ($x->isNotEmpty()) { | ||
foreach ($x as $key => $value) { | ||
$data->put($key, $value); | ||
} | ||
} | ||
} | ||
|
||
if ($data->has('site_name') && $data->get('site_name') === 'Vimeo') { | ||
$vimeo = $this->fetchOEmbed( | ||
service: 'https://vimeo.com/api/oembed.json', | ||
options: [ | ||
'maxwidth' => '446', | ||
'maxheight' => '251', | ||
] | ||
); | ||
if ($vimeo->isNotEmpty()) { | ||
foreach ($vimeo as $key => $value) { | ||
$data->put($key, $value); | ||
} | ||
} | ||
} | ||
|
||
if ($data->has('site_name') && $data->get('site_name') === 'YouTube') { | ||
$youtube = $this->fetchOEmbed( | ||
service: 'https://www.youtube.com/oembed', | ||
options: [ | ||
'maxwidth' => '446', | ||
'maxheight' => '251', | ||
]); | ||
if ($youtube->isNotEmpty()) { | ||
foreach ($youtube as $key => $value) { | ||
$data->put($key, $value); | ||
} | ||
} | ||
} | ||
|
||
return $data->unique(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,26 +1,60 @@ | ||
const themeSwitch = () => ({ | ||
|
||
theme: 'system', | ||
currentTheme: null, | ||
|
||
init() { | ||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme); | ||
|
||
const currentTheme = localStorage.getItem('theme') || this.theme; | ||
|
||
this.setTheme(currentTheme); | ||
const savedTheme = localStorage.getItem('theme') || this.theme; | ||
this.setTheme(savedTheme); | ||
}, | ||
|
||
setTheme(theme) { | ||
this.theme = theme | ||
this.theme = theme; | ||
|
||
if (theme == 'dark' || theme == 'light') { | ||
localStorage.setItem('theme', theme) | ||
if (theme === 'dark' || theme === 'light') { | ||
localStorage.setItem('theme', theme); | ||
} else { | ||
localStorage.removeItem('theme'); | ||
} | ||
|
||
updateTheme(); | ||
this.updateTheme(); | ||
}, | ||
|
||
getCurrentTheme() { | ||
if (this.theme === 'system') { | ||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | ||
} | ||
return this.theme; | ||
}, | ||
|
||
updateTheme() { | ||
const newTheme = this.getCurrentTheme(); | ||
|
||
document.documentElement.classList.remove('dark', 'light'); | ||
document.documentElement.classList.add(newTheme); | ||
|
||
if (this.currentTheme !== newTheme) { | ||
this.currentTheme = newTheme; | ||
this.renderTweets(newTheme); | ||
} | ||
}, | ||
|
||
renderTweets(theme) { | ||
const tweetContainers = document.querySelectorAll('div[data-tweet-id]'); | ||
|
||
if (tweetContainers.length > 0) { | ||
tweetContainers.forEach(container => { | ||
container.innerHTML = ''; | ||
|
||
window.twttr.widgets.createTweet(container.dataset.tweetId, container, { | ||
theme: theme, | ||
conversation: 'none', | ||
align: 'center', | ||
}); | ||
}); | ||
} | ||
}, | ||
}) | ||
}); | ||
|
||
export { themeSwitch } | ||
export { themeSwitch }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
<div | ||
id="link-preview-card" | ||
data-url="{{ $url }}" | ||
class="mx-auto mt-2 min-w-full group/preview" data-navigate-ignore="true" | ||
> | ||
@if ($data->has('html')) | ||
@if ($data->get('site_name') === 'X (formerly Twitter)') | ||
@php($tweetId = explode('/', $url)) | ||
<div data-tweet-id="{{ end($tweetId) }}" class="w-full overflow-hidden"> | ||
{!! $data->get('html') !!} | ||
</div> | ||
@else | ||
<div class="w-full overflow-hidden rounded-lg border border-slate-300 dark:border-0"> | ||
{!! $data->get('html') !!} | ||
</div> | ||
@endif | ||
@elseif($data->has('image')) | ||
@php($shortUrl = parse_url($url)['host']) | ||
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer"> | ||
<div | ||
title="Click to visit: {{ $shortUrl }}" | ||
class="relative w-full bg-slate-100/90 border border-slate-300 | ||
dark:border-0 rounded-lg dark:group-hover/preview:border-0 overflow-hidden"> | ||
<img | ||
src="{{ $data->get('image') }}" | ||
alt="{{ $data->get('title') ?? $url }}" | ||
class="object-cover object-center w-full h-56" | ||
/> | ||
<div | ||
class="absolute right-0 bottom-0 left-0 w-full rounded-b-lg border-0 bg-pink-100 bg-opacity-75 p-2 backdrop-blur-sm backdrop-filter dark:bg-opacity-45 dark:bg-pink-800"> | ||
<h3 class="text-sm font-semibold truncate text-slate-500/90 dark:text-white/90 | ||
"> | ||
{{ $data->get('title') ?? $data->get('site_name') ?? $url }}</h3> | ||
</div> | ||
</div> | ||
</a> | ||
<div class="flex items-center justify-between pt-4"> | ||
<a href="{{ $url }}" target="_blank" rel="noopener noreferrer" | ||
class="text-xs text-slate-500 group-hover/preview:text-pink-600">From: {{ $shortUrl}}</a> | ||
</div> | ||
@endif | ||
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.