Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
985a15d
refactor: return typed link preview metadata
devjerry0 Oct 20, 2026
a66fa5e
refactor: reuse Symfony ranges for preview address checks
devjerry0 Oct 20, 2026
aabddce
fix: bound preview bodies and redirect request budgets
devjerry0 Oct 20, 2026
426d98f
feat: resolve Giphy share links without API keys
devjerry0 Oct 20, 2026
40fb0fa
feat: verify and persist external GIF preview URLs
devjerry0 Oct 20, 2026
5e4b2f8
feat: define safe GIF playback and autoplay policy
devjerry0 Oct 20, 2026
2ba852c
feat: render external GIFs with accessible playback controls
devjerry0 Oct 20, 2026
24faa49
test: automate GIF security mutation checks in CI
devjerry0 Oct 20, 2026
162ef7c
docs: explain external GIF support and security limits
devjerry0 Oct 20, 2026
a7f1fa6
chore: separate GIF tests from upstream test additions
devjerry0 Oct 20, 2026
544a63f
Merge upstream production into feat/gif-support
devjerry0 Oct 20, 2026
37cb6d3
docs: keep GIF embed instructions concise
devjerry0 Oct 20, 2026
1c89254
style: keep GIF playback controls small and unobtrusive
devjerry0 Oct 20, 2026
40e510e
fix: autoplay verified external GIFs while visible
devjerry0 Oct 20, 2026
a2f9d82
fix: retain GIF rendering alongside upstream embed resizing
devjerry0 Oct 20, 2026
7a2f7b5
Merge upstream production into feat/gif-support
devjerry0 Oct 20, 2026
789e512
style: normalize embed imports after upstream merge
devjerry0 Oct 20, 2026
e7902b1
fix(security): pin non-public link preview address ranges
devjerry0 Oct 21, 2026
20bbd02
test: cover servers that ignore GIF range requests
devjerry0 Oct 21, 2026
070eb47
fix: stabilize GIF loading, playback, and manual retries
devjerry0 Oct 21, 2026
61056e3
fix: bound publication retries and serialize unfurl workers
devjerry0 Oct 21, 2026
621bc9a
feat: queue draft link previews through the shared cache
devjerry0 Oct 21, 2026
7cd2f66
feat: show link previews while composing commits and replies
devjerry0 Oct 21, 2026
a2a9749
refactor: simplify draft preview request handling
devjerry0 Oct 21, 2026
4e152c4
refactor: separate link preview polling from rendering
devjerry0 Oct 21, 2026
f92df4c
refactor: simplify GIF and draft preview rendering
devjerry0 Oct 21, 2026
6bb0f18
chore: merge upstream production into GIF support
devjerry0 Oct 22, 2026
0a6d11c
chore: merge upstream production into GIF support
devjerry0 Oct 22, 2026
6937d74
chore: merge upstream production into GIF support
devjerry0 Oct 23, 2026
d2c7819
chore: merge upstream production into GIF support
devjerry0 Oct 26, 2026
5c02ed7
fix: validate GIF sources before manual playback
devjerry0 Oct 26, 2026
0b9d8dd
chore: merge upstream production into GIF support
devjerry0 Oct 26, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -146,5 +146,8 @@ jobs:
if: steps.build-cache.outputs.cache-hit != 'true'
run: bun run build

- name: GIF Security Mutations
run: composer test:mutations

- name: Tests
run: composer test:unit
4 changes: 3 additions & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](reso

### Draft previews

Use the composer's eye button or **⌘/Ctrl+Shift+P** to preview a commit, reply, or quote before submitting it. Preview shows the rendered code, emoji, mentions, links, and images, and identifies the link selected for a card. Esc returns from preview to writing.
Use the composer's eye button or **⌘/Ctrl+Shift+P** to preview a commit, reply, or quote. The selected link card also loads while writing; direct GIFs appear after verification. Selected URLs and metadata share the posted-link cache, even if the draft is never published. Uncached links load in the background. Esc returns to writing.

Draft text is not scanned for secrets before publishing or saving locally.

Expand All @@ -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
```

Link cards preview while writing. 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
16 changes: 5 additions & 11 deletions app/Actions/CreateCommit.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
use App\Jobs\EmbedCommit;
use App\Jobs\ModerateCommit;
use App\Jobs\ReplyAsTom;
use App\Jobs\UnfurlLink;
use App\Models\Commit;
use App\Models\LinkPreview;
use App\Models\User;
Expand All @@ -31,6 +30,7 @@ public function __construct(
private CreateMentionNotifications $createMentionNotifications,
private CreateCommitImages $createCommitImages,
private CreateWorldReply $createWorldReply,
private CreateLinkPreviewJob $createLinkPreviewJob,
private UpdateCommitHotScores $updateCommitHotScores,
private TheAlgorithm $theAlgorithm,
private EmojiShortcodes $emojiShortcodes,
Expand All @@ -42,8 +42,6 @@ public function __construct(
}

/**
* The link preview is of the first URL in the body when true, of the given URL when a string, or none when false.
*
* @param list<UploadedFile> $images
* @param array<int, string|null> $imageDescriptions
*/
Expand Down Expand Up @@ -86,8 +84,10 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit
$this->createQuoteNotifications->handle($commit);
$this->createMentionNotifications->handle($commit);

if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) {
dispatch(new UnfurlLink($linkPreview));
if ($linkPreview instanceof LinkPreview && $linkPreview->prepareUnfurlForPublication()) {
DB::afterCommit(function () use ($linkPreview): void {
$this->createLinkPreviewJob->handle($linkPreview);
});
}

$this->createWorldReply->handle($commit);
Expand Down Expand Up @@ -118,9 +118,6 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit
});
}

/**
* A poll opens with its commit's approval, so polls of pending commits wait for moderation.
*/
private function createPoll(Commit $commit, NewPoll $newPoll): void
{
$poll = $commit->poll()->create([
Expand All @@ -135,9 +132,6 @@ private function createPoll(Commit $commit, NewPoll $newPoll): void
));
}

/**
* Commits that link to the same URL share one preview, so a page is fetched once however often it is linked.
*/
private function linkPreviewFor(?string $url): ?LinkPreview
{
if ($url === null) {
Expand Down
31 changes: 31 additions & 0 deletions app/Actions/CreateLinkPreviewJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Jobs\UnfurlLink;
use App\Models\LinkPreview;
use Throwable;

final readonly class CreateLinkPreviewJob
{
public function handle(LinkPreview $linkPreview): void
{
$preview = $linkPreview->fresh();

if (! $preview instanceof LinkPreview || $preview->fetched_at !== null) {
return;
}

$job = new UnfurlLink($preview);

try {
dispatch($job)->afterCommit();
} catch (Throwable $throwable) {
$job->failed();

throw $throwable;
}
}
}
15 changes: 5 additions & 10 deletions app/Actions/CreateMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use App\Enums\RemoteStatus;
use App\Jobs\SendRemotePush;
use App\Jobs\UnfurlLink;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\LinkPreview;
Expand All @@ -28,13 +27,11 @@ public function __construct(
private CreateMessageVoiceFile $createMessageVoiceFile,
private MentionedUsersQuery $mentionedUsers,
private Urls $urls,
private CreateLinkPreviewJob $createLinkPreviewJob,
) {
//
}

/**
* Makes the message the conversation's latest, for every member's inbox.
*/
public static function advance(Conversation $conversation, Message $message, bool $unstash): void
{
Conversation::query()
Expand All @@ -48,10 +45,6 @@ public static function advance(Conversation $conversation, Message $message, boo
}

/**
* Sending the same client id again, as a retry does, returns the message it already created, without storing its
* images or voice note twice. A new message brings the conversation back out of the stash for everyone in it. The link card is
* of the first link in the body when true, of the given link when a string, or none when false.
*
* @param list<UploadedFile> $images
* @param array<int, string|null> $imageDescriptions
* @param list<int> $voiceWaveform
Expand Down Expand Up @@ -108,8 +101,10 @@ public function handle(User $sender, Conversation $conversation, string $body, s
->where('read_through_id', '<', $message->id)
->update(['read_through_id' => $message->id, 'read_at' => now()]);

if ($linkPreview instanceof LinkPreview && $linkPreview->fetched_at === null) {
dispatch(new UnfurlLink($linkPreview))->afterCommit();
if ($linkPreview instanceof LinkPreview && $linkPreview->prepareUnfurlForPublication()) {
DB::afterCommit(function () use ($linkPreview): void {
$this->createLinkPreviewJob->handle($linkPreview);
});
}

return $message;
Expand Down
Loading