diff --git a/app/Services/MetaData.php b/app/Services/MetaData.php new file mode 100644 index 000000000..e7950bc70 --- /dev/null +++ b/app/Services/MetaData.php @@ -0,0 +1,178 @@ + + */ + public function fetch(): Collection + { + /** @var Collection $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 + */ + 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 $options + * @return Collection + */ + 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 + */ + 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(); + } +} diff --git a/app/Services/ParsableContentProviders/LinkProviderParsable.php b/app/Services/ParsableContentProviders/LinkProviderParsable.php index 4539e2b6f..8764ca1af 100644 --- a/app/Services/ParsableContentProviders/LinkProviderParsable.php +++ b/app/Services/ParsableContentProviders/LinkProviderParsable.php @@ -5,6 +5,7 @@ namespace App\Services\ParsableContentProviders; use App\Contracts\Services\ParsableContentProvider; +use App\Services\MetaData; use Illuminate\Support\Str; final readonly class LinkProviderParsable implements ParsableContentProvider @@ -37,6 +38,22 @@ function (array $matches): string { $url = $isMail ? 'mailto:'.$humanUrl : $url; + if (! $isMail && $url) { + $service = new MetaData($url); + $metadata = $service->fetch(); + + if ($metadata->isNotEmpty() && ($metadata->has('image') || $metadata->has('html'))) { + $trimmed = trim( + view('components.link-preview-card', [ + 'data' => $metadata, + 'url' => $url, + ])->render() + ); + + return (string) preg_replace('//', '', $trimmed); + } + } + return ''.$humanUrl.''; }, str_replace('&', '&', $content) diff --git a/resources/js/theme-switch.js b/resources/js/theme-switch.js index 1c13eb19f..58e5d2b07 100644 --- a/resources/js/theme-switch.js +++ b/resources/js/theme-switch.js @@ -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 }; diff --git a/resources/views/components/link-preview-card.blade.php b/resources/views/components/link-preview-card.blade.php new file mode 100644 index 000000000..a800c8383 --- /dev/null +++ b/resources/views/components/link-preview-card.blade.php @@ -0,0 +1,42 @@ + diff --git a/resources/views/layouts/components/head.blade.php b/resources/views/layouts/components/head.blade.php index 7d3d00a69..9c0ccd4a6 100644 --- a/resources/views/layouts/components/head.blade.php +++ b/resources/views/layouts/components/head.blade.php @@ -207,6 +207,8 @@ } + +