diff --git a/app/Actions/CreateCommit.php b/app/Actions/CreateCommit.php index ee292d898..7881f4b24 100644 --- a/app/Actions/CreateCommit.php +++ b/app/Actions/CreateCommit.php @@ -4,6 +4,7 @@ namespace App\Actions; +use App\Dto\Gif; use App\Dto\NewPoll; use App\Dto\ThreadEngagement; use App\Enums\CommitStatus; @@ -51,7 +52,7 @@ public function __construct( * @param list $images * @param array $imageDescriptions */ - public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null): Commit + public function handle(User $user, string $body, ?Commit $parent = null, ?Commit $quotedCommit = null, array $images = [], array $imageDescriptions = [], bool|string $linkPreview = true, ?NewPoll $poll = null, ?Gif $gif = null): Commit { $url = match ($linkPreview) { true => $this->urls->first($body), @@ -59,7 +60,7 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit default => $linkPreview, }; - return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll): Commit { + return DB::transaction(function () use ($user, $body, $parent, $quotedCommit, $images, $imageDescriptions, $url, $poll, $gif): Commit { $linkPreview = $this->linkPreviewFor($url); $createdAt = now(); $hasGoodReputation = $user->hasGoodReputation(); @@ -77,6 +78,10 @@ public function handle(User $user, string $body, ?Commit $parent = null, ?Commit 'created_at' => $createdAt, ]); + if ($gif instanceof Gif) { + $commit->gif()->create($gif->toColumns()); + } + $this->createCommitTopics->handle($commit, $this->mentionedOrganizations->get($body, $mentions)); $this->createCommitImages->handle($commit, $images, $imageDescriptions); diff --git a/app/Actions/CreateUserFeed.php b/app/Actions/CreateUserFeed.php index 145731b7a..cd7905c08 100644 --- a/app/Actions/CreateUserFeed.php +++ b/app/Actions/CreateUserFeed.php @@ -71,6 +71,10 @@ private function entry(XMLWriter $xml, User $user, Commit $commit): void $this->link($xml, 'enclosure', $image->url(), 'image/webp'); } + if ($commit->gif !== null) { + $this->link($xml, 'enclosure', $commit->gif->url, 'image/gif'); + } + $xml->writeElement('published', $commit->created_at->toAtomString()); $xml->writeElement('updated', $commit->created_at->toAtomString()); diff --git a/app/Actions/UpdateCommitAiDecision.php b/app/Actions/UpdateCommitAiDecision.php index 57c98e791..b9f17af45 100644 --- a/app/Actions/UpdateCommitAiDecision.php +++ b/app/Actions/UpdateCommitAiDecision.php @@ -8,7 +8,11 @@ use App\Models\Commit; use App\Models\CommitImage; use App\Services\OpenAI; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Image; use Illuminate\Support\Facades\Storage; +use RuntimeException; +use Throwable; final readonly class UpdateCommitAiDecision { @@ -17,6 +21,10 @@ */ private const float AUTO_REJECT_THRESHOLD = 0.95; + private const int GIF_MAX_BYTES = 20 * 1024 * 1024; + + private const int GIF_MAX_DIMENSION = 1600; + public function __construct( private OpenAI $openAI, private UpdateCommitStatuses $updateCommitStatuses, @@ -25,7 +33,7 @@ public function __construct( } /** - * Asks the AI whether the commit, images included, should be approved. The decision is mostly a hint for + * Asks the AI whether the commit, images and GIF included, should be approved. The decision is mostly a hint for * moderators: a clean commit is approved, and it is rejected, without a reason, when the AI is all but certain. * Failures, such as timeouts or rate limits, are thrown, so the caller can try again later. */ @@ -35,6 +43,10 @@ public function handle(Commit $commit): void fn (CommitImage $image): string => 'data:image/webp;base64,'.base64_encode((string) Storage::get($image->path)), )->all()); + if ($commit->gif !== null) { + $images[] = $this->gifForModeration($commit->gif->url); + } + $scores = $this->openAI->moderate($commit->body, $images); $categories = array_keys($scores); @@ -56,4 +68,30 @@ public function handle(Commit $commit): void $this->updateCommitStatuses->handle([$commit->id], CommitStatus::Approved, onlyPending: true); } } + + /** + * The moderation endpoint only accepts non-animated GIFs of at most 20 MB, while providers serve animated ones. + * Sending their first frame as WebP keeps the check on the image itself instead of relying on the browser filter. + */ + private function gifForModeration(string $url): string + { + $response = Http::timeout(10)->withoutRedirecting()->get($url)->throw(); + $body = $response->body(); + + if (mb_strlen($body) > self::GIF_MAX_BYTES) { + throw new RuntimeException('The GIF is too large to moderate.'); + } + + try { + $webp = Image::fromBytes($body) + ->usingGd() + ->scale(self::GIF_MAX_DIMENSION, self::GIF_MAX_DIMENSION) + ->optimize('webp', 60) + ->toBytes(); + } catch (Throwable $throwable) { + throw new RuntimeException('The GIF could not be prepared for moderation.', $throwable->getCode(), previous: $throwable); + } + + return 'data:image/webp;base64,'.base64_encode($webp); + } } diff --git a/app/Dto/Gif.php b/app/Dto/Gif.php new file mode 100644 index 000000000..37d25dc23 --- /dev/null +++ b/app/Dto/Gif.php @@ -0,0 +1,41 @@ + + */ + public function toColumns(): array + { + return [ + 'provider' => $this->provider->value, + 'provider_gif_id' => $this->id, + 'url' => $this->url, + 'page_url' => $this->pageUrl, + 'title' => $this->title, + 'username' => $this->username, + 'width' => $this->width, + 'height' => $this->height, + ]; + } +} diff --git a/app/Enums/GifProvider.php b/app/Enums/GifProvider.php new file mode 100644 index 000000000..7c0738cf8 --- /dev/null +++ b/app/Enums/GifProvider.php @@ -0,0 +1,41 @@ + 'GIPHY', + self::Klipy => 'KLIPY', + }; + } + + /** + * @return list + */ + public function mediaHostPatterns(): array + { + return match ($this) { + self::Giphy => ['/^media\d*\.giphy\.com$/'], + self::Klipy => ['/^static\d*\.klipy\.com$/'], + }; + } + + /** + * @return list + */ + public function pageHostPatterns(): array + { + return match ($this) { + self::Giphy => ['/^(www\.)?giphy\.com$/'], + self::Klipy => ['/^(www\.)?klipy\.com$/'], + }; + } +} diff --git a/app/Http/Controllers/CommitController.php b/app/Http/Controllers/CommitController.php index b6ad709bc..6e284b056 100644 --- a/app/Http/Controllers/CommitController.php +++ b/app/Http/Controllers/CommitController.php @@ -66,7 +66,7 @@ public function store(CreateCommitRequest $request, #[CurrentUser] User $user, C { $before = $achievements->unlockedBadgeNames($user); - $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll()); + $commit = $action->handle($user, $request->string('body')->value(), quotedCommit: $request->quotedCommit(), images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), poll: $request->poll(), gif: $request->gif()); $unlocked = $achievements->unlockedBadgeNames($user)->diff($before)->values(); diff --git a/app/Http/Controllers/CommitReplyController.php b/app/Http/Controllers/CommitReplyController.php index e7da33c70..56ce7eec3 100644 --- a/app/Http/Controllers/CommitReplyController.php +++ b/app/Http/Controllers/CommitReplyController.php @@ -26,7 +26,7 @@ public function index(ListCommitRepliesRequest $request, #[CurrentUser] ?User $u public function store(CreateCommitReplyRequest $request, #[CurrentUser] User $user, Commit $commit, CreateCommit $action, CommitThreadQuery $thread): JsonResponse { - $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview()); + $reply = $action->handle($user, $request->string('body')->value(), $commit, images: $request->images(), imageDescriptions: $request->imageDescriptions(), linkPreview: $request->linkPreview(), gif: $request->gif()); $parent = $thread->get($commit, $user); return response()->json([ diff --git a/app/Http/Requests/CreateCommitReplyRequest.php b/app/Http/Requests/CreateCommitReplyRequest.php index da3487990..e941effef 100644 --- a/app/Http/Requests/CreateCommitReplyRequest.php +++ b/app/Http/Requests/CreateCommitReplyRequest.php @@ -5,6 +5,8 @@ namespace App\Http\Requests; use App\Actions\CreateCommitImages; +use App\Dto\Gif; +use App\Enums\GifProvider; use App\Models\Commit; use App\Models\User; use App\Services\Urls; @@ -14,6 +16,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Dimensions; +use Illuminate\Validation\Rules\Enum; use Illuminate\Validation\Rules\File; use Illuminate\Validation\Rules\In; @@ -38,11 +41,13 @@ public function prepareForValidation(): void /** * Images are checked by their contents, not their extension, and SVG is never accepted since it can carry scripts. * - * @return array> + * @return array> */ public function rules(): array { $body = $this->input('body'); + $rawProvider = $this->input('gif_provider'); + $provider = is_string($rawProvider) ? (GifProvider::tryFrom($rawProvider) ?? GifProvider::Giphy) : GifProvider::Giphy; return [ 'body' => CreateCommitRequest::bodyRules($this->hasFile('images')), @@ -58,6 +63,7 @@ public function rules(): array ], 'image_descriptions' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES], 'image_descriptions.*' => ['nullable', 'string', 'max:'.CreateCommitImages::MAX_DESCRIPTION_LENGTH], + ...CreateCommitRequest::gifRules($provider), ]; } @@ -113,4 +119,22 @@ public function imageDescriptions(): array return $descriptions; } + + public function gif(): ?Gif + { + if (! $this->filled('gif_id')) { + return null; + } + + return new Gif( + GifProvider::from($this->string('gif_provider')->value()), + $this->string('gif_id')->value(), + $this->string('gif_url')->value(), + $this->string('gif_page_url')->value(), + $this->filled('gif_title') ? $this->string('gif_title')->value() : null, + $this->filled('gif_username') ? $this->string('gif_username')->value() : null, + $this->integer('gif_width'), + $this->integer('gif_height'), + ); + } } diff --git a/app/Http/Requests/CreateCommitRequest.php b/app/Http/Requests/CreateCommitRequest.php index 338db8c30..c7af5e42a 100644 --- a/app/Http/Requests/CreateCommitRequest.php +++ b/app/Http/Requests/CreateCommitRequest.php @@ -5,11 +5,14 @@ namespace App\Http\Requests; use App\Actions\CreateCommitImages; +use App\Dto\Gif; use App\Dto\NewPoll; +use App\Enums\GifProvider; use App\Enums\PollDuration; use App\Models\Commit; use App\Models\User; use App\Rules\CommitBodyLength; +use App\Rules\GifUrl; use App\Rules\VisibleCommit; use App\Services\CommitBodies; use App\Services\Urls; @@ -49,7 +52,7 @@ public static function normalizeBody(FormRequest $request): void * * @return array> */ - public static function attachmentRules(): array + public static function attachmentRules(GifProvider $provider = GifProvider::Giphy): array { return [ 'poll' => ['nullable', 'array:options,duration', 'prohibits:parent_id,images'], @@ -67,6 +70,26 @@ public static function attachmentRules(): array ], 'image_descriptions' => ['nullable', 'list', 'max:'.CreateCommitImages::MAX_IMAGES], 'image_descriptions.*' => ['nullable', 'string', 'max:'.CreateCommitImages::MAX_DESCRIPTION_LENGTH], + ...self::gifRules($provider), + ]; + } + + /** + * Shared by top-level commits and replies; replies have no poll field, but gif_id still prohibits one. + * + * @return array> + */ + public static function gifRules(GifProvider $provider = GifProvider::Giphy): array + { + return [ + 'gif_provider' => ['nullable', 'string', Rule::enum(GifProvider::class), 'required_with:gif_id'], + 'gif_id' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9-]+$/', 'prohibits:images,poll', 'required_with:gif_url'], + 'gif_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifUrl($provider)], + 'gif_page_url' => ['nullable', 'string', 'max:2048', 'required_with:gif_id', new GifUrl($provider, page: true)], + 'gif_title' => ['nullable', 'string', 'max:300'], + 'gif_username' => ['nullable', 'string', 'max:100'], + 'gif_width' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], + 'gif_height' => ['nullable', 'integer', 'min:1', 'max:10000', 'required_with:gif_id'], ]; } @@ -83,7 +106,10 @@ public function rules(): array $user = $this->user(); assert($user instanceof User); $body = $this->input('body'); - $attachmentRules = self::attachmentRules(); + $rawProvider = $this->input('gif_provider'); + $provider = is_string($rawProvider) ? (GifProvider::tryFrom($rawProvider) ?? GifProvider::Giphy) : GifProvider::Giphy; + + $attachmentRules = self::attachmentRules($provider); $attachmentRules['poll'][] = 'prohibits:quoted_commit_id'; return [ @@ -169,6 +195,24 @@ public function quotedCommit(): ?Commit : null; } + public function gif(): ?Gif + { + if (! $this->filled('gif_id')) { + return null; + } + + return new Gif( + GifProvider::from($this->string('gif_provider')->value()), + $this->string('gif_id')->value(), + $this->string('gif_url')->value(), + $this->string('gif_page_url')->value(), + $this->filled('gif_title') ? $this->string('gif_title')->value() : null, + $this->filled('gif_username') ? $this->string('gif_username')->value() : null, + $this->integer('gif_width'), + $this->integer('gif_height'), + ); + } + public function prepareForValidation(): void { self::normalizeBody($this); diff --git a/app/Http/Resources/CommitResource.php b/app/Http/Resources/CommitResource.php index f8340df0c..46c661772 100644 --- a/app/Http/Resources/CommitResource.php +++ b/app/Http/Resources/CommitResource.php @@ -65,6 +65,7 @@ public function toArray(Request $request): array 'images' => $this->whenLoaded('images', fn (): mixed => $this->status === CommitStatus::Approved || ! $this->imagesAreVisibleTo($request->user()) ? $this->images : $this->images->map(fn (CommitImage $image): array => [...$image->toArray(), 'url' => $image->temporaryUrl()])), + 'gif' => $this->gif?->toPayload(), 'author' => UserResource::make($this->user), ]; diff --git a/app/Models/Commit.php b/app/Models/Commit.php index e91a35d4c..8acd0c1f8 100644 --- a/app/Models/Commit.php +++ b/app/Models/Commit.php @@ -53,6 +53,7 @@ * @property-read string|null $continuation_id * @property-read LinkPreview|null $linkPreview * @property-read Poll|null $poll + * @property-read CommitGif|null $gif * @property-read Commit|null $quotedCommit * @property-read Collection $quotes * @property-read Collection $images @@ -148,7 +149,7 @@ public function toArray(): array $this->segments = resolve(CommitBodies::class)->segments($this->body); } - return [...parent::toArray(), ...$this->segments, 'is_checking' => $this->isBeingChecked()]; + return [...parent::toArray(), 'gif' => $this->gif?->toPayload(), ...$this->segments, 'is_checking' => $this->isBeingChecked()]; } /** @@ -251,6 +252,14 @@ public function poll(): HasOne return $this->hasOne(Poll::class); } + /** + * @return HasOne + */ + public function gif(): HasOne + { + return $this->hasOne(CommitGif::class); + } + /** * @return BelongsTo */ diff --git a/app/Models/CommitGif.php b/app/Models/CommitGif.php new file mode 100644 index 000000000..b40980059 --- /dev/null +++ b/app/Models/CommitGif.php @@ -0,0 +1,84 @@ +> */ + use HasFactory; + + use HasUuids; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'string', + 'commit_id' => 'string', + 'provider' => GifProvider::class, + 'provider_gif_id' => 'string', + 'url' => 'string', + 'page_url' => 'string', + 'title' => 'string', + 'username' => 'string', + 'width' => 'integer', + 'height' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function commit(): BelongsTo + { + return $this->belongsTo(Commit::class); + } + + /** + * The GIF as every read surface exposes it. + * + * @return array{provider: string, id: string, url: string, page_url: string, title: string|null, username: string|null, width: int, height: int} + */ + public function toPayload(): array + { + return [ + 'provider' => $this->provider->value, + 'id' => $this->provider_gif_id, + 'url' => $this->url, + 'page_url' => $this->page_url, + 'title' => $this->title, + 'username' => $this->username, + 'width' => $this->width, + 'height' => $this->height, + ]; + } +} diff --git a/app/Queries/CommitBuilder.php b/app/Queries/CommitBuilder.php index 7dbd9686d..1d5828b42 100644 --- a/app/Queries/CommitBuilder.php +++ b/app/Queries/CommitBuilder.php @@ -36,6 +36,13 @@ final class CommitBuilder extends Builder */ private const string ORGANIZATION_MENTIONS = "(select json_object_agg(reference, topic_id) from commit_topics where commit_topics.commit_id = commits.id and topic_type = 'organization') as organization_mentions"; + public function __construct(QueryBuilder $query) + { + parent::__construct($query); + + $this->with('gif'); + } + /** * Embeddings are only ever compared within the database, so commits are loaded without theirs rather than carry * kilobytes of numbers each that nothing reads. They come with the organizations they mention instead, by lowercase @@ -62,6 +69,28 @@ public function getModels($columns = ['*']): array } } + /** + * Only a commit with its id can be serialized, which is the only time the gif is read. + * + * @param array $models + * @return array + */ + public function eagerLoadRelations(array $models): array + { + $eagerLoads = $this->getEagerLoads(); + $first = array_first($models); + + if ($first instanceof Commit && ! $this->hasEveryColumn($first)) { + $this->without('gif'); + } + + try { + return parent::eagerLoadRelations(array_values($models)); + } finally { + $this->setEagerLoads($eagerLoads); + } + } + /** * Approved commits are visible to everyone, guests included, while authors may also see their own pending or rejected commits. * A block hides each one's commits from the other. @@ -299,6 +328,13 @@ private function whetherViewerHasRow(QueryBuilder $rows, string $commitColumn, s /** * A bare * only stands for the commit's columns when no other table is joined in. */ + private function hasEveryColumn(Commit $commit): bool + { + $attributes = $commit->getAttributes(); + + return array_all(self::COLUMNS_BUT_EMBEDDING, fn (string $column): bool => array_key_exists($column, $attributes)); + } + private function isEveryColumn(Expression|string $column): bool { return $this->query->from === $this->getModel()->getTable() diff --git a/app/Rules/GifUrl.php b/app/Rules/GifUrl.php new file mode 100644 index 000000000..7d49d3727 --- /dev/null +++ b/app/Rules/GifUrl.php @@ -0,0 +1,51 @@ +page + ? $this->provider->pageHostPatterns() + : $this->provider->mediaHostPatterns(); + + $isValid = is_array($parts) + && ($parts['scheme'] ?? '') === 'https' + && array_filter( + $patterns, + fn (string $pattern): bool => preg_match($pattern, $host) === 1, + ) !== [] + && ! isset($parts['port']) + && ! isset($parts['user']) + && ! isset($parts['pass']) + && ($this->page || ( + ! isset($parts['fragment']) + && preg_match('#^/[A-Za-z0-9._/-]+\.gif$#', $parts['path'] ?? '') === 1 + && preg_match('#(?:^|/)\.{1,2}(?:/|$)#', $parts['path']) !== 1 + )); + + if (! $isValid) { + $fail(__('The GIF could not be attached.')); + } + } +} diff --git a/app/Services/OpenAI.php b/app/Services/OpenAI.php index c801e3b78..6999743ff 100644 --- a/app/Services/OpenAI.php +++ b/app/Services/OpenAI.php @@ -155,15 +155,15 @@ public function decideRelevance(string $query, array $bodies): array * scoring above our own, stricter thresholds. The text, if any, goes along with the first image, and every other * image is checked on its own, as a single request is only documented to take one image. * - * @param list $imageDataUrls + * @param list $imageUrls data URLs or remote https URLs * @return array */ - public function moderate(string $text, array $imageDataUrls = []): array + public function moderate(string $text, array $imageUrls = []): array { $inputs = $text === '' ? [] : [[['type' => 'text', 'text' => $text]]]; - foreach ($imageDataUrls as $index => $imageDataUrl) { - $image = ['type' => 'image_url', 'image_url' => ['url' => $imageDataUrl]]; + foreach ($imageUrls as $index => $imageUrl) { + $image = ['type' => 'image_url', 'image_url' => ['url' => $imageUrl]]; if ($index === 0 && $inputs !== []) { $inputs[0][] = $image; diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index 25261e5e1..a71b04bb8 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -351,6 +351,16 @@ private function commitImages(Commit $commit, User $user): array ]; } + if ($commit->gif !== null) { + $images[] = [ + 'url' => $commit->gif->url, + 'width' => $commit->gif->width, + 'height' => $commit->gif->height, + 'type' => 'image/gif', + 'alt' => $commit->gif->title ?? "GIF attached to the commit of {$user->nameWithHandle()}", + ]; + } + return $images; } diff --git a/database/factories/CommitFactory.php b/database/factories/CommitFactory.php index 1f19fb2b6..794cb8118 100644 --- a/database/factories/CommitFactory.php +++ b/database/factories/CommitFactory.php @@ -6,6 +6,7 @@ use App\Dto\ThreadEngagement; use App\Enums\CommitStatus; +use App\Enums\GifProvider; use App\Models\Commit; use App\Models\User; use App\Services\TheAlgorithm; @@ -58,4 +59,32 @@ public function rejected(): self 'status' => CommitStatus::Rejected, ]); } + + public function withGif(GifProvider $provider = GifProvider::Giphy): self + { + $gif = match ($provider) { + GifProvider::Giphy => [ + 'provider_gif_id' => 'xT9IgzoKnwFNmISR8I', + 'url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + 'page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'title' => 'Deal With It GIF', + 'username' => 'giphy', + 'width' => 480, + 'height' => 270, + ], + GifProvider::Klipy => [ + 'provider_gif_id' => 'hello-hi-662', + 'url' => 'https://static.klipy.com/ii/935d7ab9/kLipy.gif', + 'page_url' => 'https://klipy.com/gifs/hello-hi-662', + 'title' => 'Hello', + 'username' => null, + 'width' => 498, + 'height' => 498, + ], + }; + + return $this->afterCreating(function (Commit $commit) use ($provider, $gif): void { + $commit->gif()->create(['provider' => $provider->value, ...$gif]); + }); + } } diff --git a/database/factories/CommitGifFactory.php b/database/factories/CommitGifFactory.php new file mode 100644 index 000000000..4e9c147c2 --- /dev/null +++ b/database/factories/CommitGifFactory.php @@ -0,0 +1,34 @@ + + */ +final class CommitGifFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'commit_id' => Commit::factory(), + 'provider' => GifProvider::Giphy->value, + 'provider_gif_id' => 'xT9IgzoKnwFNmISR8I', + 'url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + 'page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'title' => 'Deal With It GIF', + 'username' => 'giphy', + 'width' => 480, + 'height' => 270, + ]; + } +} diff --git a/database/migrations/2026_10_17_024509_create_commit_gifs_table.php b/database/migrations/2026_10_17_024509_create_commit_gifs_table.php new file mode 100644 index 000000000..1525a8074 --- /dev/null +++ b/database/migrations/2026_10_17_024509_create_commit_gifs_table.php @@ -0,0 +1,27 @@ +uuid('id')->primary(); + $table->foreignUuid('commit_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('provider', 16); + $table->string('provider_gif_id', 64); + $table->text('url'); + $table->text('page_url'); + $table->string('title', 300)->nullable(); + $table->string('username', 100)->nullable(); + $table->unsignedSmallInteger('width'); + $table->unsignedSmallInteger('height'); + $table->timestamps(); + }); + } +}; diff --git a/resources/js/components/commit-card.tsx b/resources/js/components/commit-card.tsx index 9b08b7530..00f8fdef2 100644 --- a/resources/js/components/commit-card.tsx +++ b/resources/js/components/commit-card.tsx @@ -5,6 +5,7 @@ import { useCallback, useContext, useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import CommitLinkPreviewController from '@/actions/App/Http/Controllers/CommitLinkPreviewController'; import { FoldedCommitBody } from '@/components/commit-body'; +import { CommitGif } from '@/components/commit-gif'; import { CommitImages } from '@/components/commit-images'; import { CommitPastelButton } from '@/components/commit-pastel-button'; import { CommitPoll } from '@/components/commit-poll'; @@ -473,6 +474,9 @@ export function CommitCard({ {translation.notice} {showImages && } + {showImages && commit.gif && ( + + )} {commit.poll && ( ; onKeyDown: (event: KeyboardEvent) => void; drop: () => void; + gifKey: GifProviderKey | null; + gifOpen: boolean; + onGifOpenChange: (open: boolean) => void; }) { const keys = useKeyLabels(); const { body, isRestored } = stash; @@ -137,6 +148,7 @@ function CommitComposerBody({ setIsScheduling, } = schedule; const hasImages = attachments.images.length > 0; + const isSchedulingGif = isScheduling && stash.gif !== null; const progress = bodyProgress( issue.command !== null ? characterCount(body) @@ -152,6 +164,11 @@ function CommitComposerBody({ const textareaRef = useRef(null); const textBox = useRef(null); const preview = useComposerPreview({ body, textareaRef, isSubmittable }); + const pickGif = (gif: GifAttachment) => { + attachments.clear(); + setPoll(null); + stash.updateGif(gif); + }; const isIssue = issue.command !== null; // When the dialog's text box shrinks, as the keyboard opens or an image is added, it keeps the caret in view. @@ -294,6 +311,10 @@ function CommitComposerBody({ )} + {preview.isPreviewing && stash.gif !== null && ( + + )} + {issue.hint && ( )} + {!preview.isPreviewing && ( + stash.updateGif(null)} + /> + )} @@ -408,18 +440,29 @@ function CommitComposerBody({
{!isIssue && ( <> - { + stash.updateGif(null); + return attachments.pick(files); + }} + onPickGif={pickGif} className="-ml-2" /> + + GIF + +
+ + ); +} diff --git a/resources/js/components/commit-image-picker.tsx b/resources/js/components/commit-image-picker.tsx index 071453fee..e09e3ca73 100644 --- a/resources/js/components/commit-image-picker.tsx +++ b/resources/js/components/commit-image-picker.tsx @@ -1,9 +1,19 @@ -import { ImagePlus, X } from 'lucide-react'; +import { ImagePlay, ImagePlus, X } from 'lucide-react'; import { useId, useRef, useState } from 'react'; import { ActionTooltip } from '@/components/action-tooltip'; import type { ShownImage } from '@/components/commit-images'; +import { GifPickerDialog } from '@/components/gif-picker-dialog'; import { ImageViewer } from '@/components/image-viewer'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { GifProviderKey } from '@/lib/gif-provider-key'; +import { gifProviderFor } from '@/lib/gif-providers'; +import type { GifAttachment } from '@/lib/gif-providers'; import { acceptedImageTypes, invalidImageMessage, @@ -45,7 +55,7 @@ export function pickImages( } /** - * The first validation error the server gave for the images, whichever image it was about. + * The first validation error the server gave for the attachments, whichever one it was about. */ export function imageErrorFrom( errors: Record, @@ -54,7 +64,8 @@ export function imageErrorFrom( (name) => name === 'images' || name.startsWith('images.') || - name.startsWith('image_descriptions'), + name.startsWith('image_descriptions') || + name.startsWith('gif_'), ); return key ? errors[key] : undefined; @@ -357,3 +368,138 @@ export function ImagePickerPreviews({ ); } + +/** + * One image affordance for the commit composers. With a stored GIF provider key it offers images or a GIF; without + * one it stays the plain image picker, so the menu never has a single dead option. + */ +export function ComposerMediaButton({ + gifKey, + gifOpen, + onGifOpenChange, + hasGif, + gifDisabled = false, + count, + disabled = false, + reading = false, + onPickImages, + onPickGif, + className, +}: { + gifKey: GifProviderKey | null; + gifOpen: boolean; + onGifOpenChange: (open: boolean) => void; + hasGif: boolean; + gifDisabled?: boolean; + count: number; + disabled?: boolean; + reading?: boolean; + onPickImages: (files: File[]) => Promise; + onPickGif: (gif: GifAttachment) => void; + className?: string; +}) { + const inputRef = useRef(null); + const canAddImages = !disabled && !reading && count < maxImages; + const openImages = () => { + if (canAddImages) { + inputRef.current?.click(); + } + }; + + return ( + <> + { + const input = event.currentTarget; + const files = Array.from(input.files ?? []); + // Allow the same selection again even if this read is discarded. + input.value = ''; + if (files.length === 0) return; + + await onPickImages(files); + }} + data-test="commit-image-input" + /> + {gifKey === null ? ( + + + + ) : ( + + + + + + + + { + openImages(); + }} + > + + Upload image + + { + onGifOpenChange(true); + }} + > + + {hasGif ? 'One GIF per commit' : 'Choose a GIF'} + + + + )} + {gifKey !== null && ( + + )} + + ); +} diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index b2501ea1c..52af1d61e 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -1,16 +1,17 @@ import { Form, usePage } from '@inertiajs/react'; import { Quote } from 'lucide-react'; import type { KeyboardEvent } from 'react'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; import { ActionTooltip } from '@/components/action-tooltip'; import { BodyCounter } from '@/components/body-counter'; +import { GifAttachmentPreview } from '@/components/commit-gif'; import { ImageDropOverlay, - ImagePickerButton, ImagePickerPreviews, imageErrorFrom, } from '@/components/commit-image-picker'; +import { ComposerMediaButton } from '@/components/commit-image-picker'; import { CommitRulesDialog, useCommitRules } from '@/components/commit-rules'; import { ComposerPreviewBody, @@ -42,6 +43,8 @@ import { maxRawBodyLength, minBodyLength, } from '@/lib/commit'; +import { useGifProviderKey } from '@/lib/gif-provider-key'; +import { gifFormFields } from '@/lib/gif-providers'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; import { characterCount, cn, isRejectedSubmission } from '@/lib/utils'; import type { Commit } from '@/types'; @@ -118,16 +121,20 @@ function QuoteComposer({ onClose: () => void; }) { const { user } = useUser(); + const gifKey = useGifProviderKey(user.id); + const [gifOpen, setGifOpen] = useState(false); const markCommitDeleted = useMarkCommitDeleted(); const id = `quote-${commit.id}`; const { body, images, descriptions, + gif, isRestored, key: stashKey, updateBody, updateImages, + updateGif, drop, } = useStash(user.id, id); const attachments = useImageAttachments( @@ -141,6 +148,12 @@ function QuoteComposer({ const commitRules = useCommitRules(); const keys = useKeyLabels(); + useEffect(() => { + if (attachments.images.length > 0 && gif !== null) { + updateGif(null); + } + }, [attachments.images.length, gif, updateGif]); + const isQuotable = (images.length > 0 || characterCount(body.trim()) >= minBodyLength) && bodyCharacterCount(body) <= maxBodyLength; @@ -214,10 +227,14 @@ function QuoteComposer({ transform={(data) => ({ ...data, commit_rules_accepted: commitRules.hasAccepted(), - ...(images.length > 0 && { - images, - image_descriptions: descriptions, - }), + ...(gif !== null + ? gifFormFields(gif) + : images.length > 0 + ? { + images, + image_descriptions: descriptions, + } + : {}), })} options={{ preserveScroll: true }} onError={(errors) => { @@ -307,12 +324,18 @@ function QuoteComposer({ /> {!preview.isPreviewing && ( - + <> + + updateGif(null)} + /> + )}
- { + updateGif(null); + return attachments.pick(files); + }} + onPickGif={(picked) => { + attachments.clear(); + updateGif(picked); + }} className="size-8" /> void; }) { const { user } = useUser(); + const gifKey = useGifProviderKey(user.id); + const [gifOpen, setGifOpen] = useState(false); const commitNotFound = useCommitNotFound(); const { body, images, descriptions, + gif, isRestored, key: stashKey, updateBody, updateImages, + updateGif, drop, } = useStash(user.id, `reply-${parent.id}`); const attachments = useImageAttachments( @@ -1368,6 +1376,14 @@ function ReplyComposer({ image_descriptions: string[]; link_preview_url?: string; commit_rules_accepted?: boolean; + gif_provider?: GifProviderId; + gif_id?: string; + gif_url?: string; + gif_page_url?: string; + gif_title?: string; + gif_username?: string; + gif_width?: number; + gif_height?: number; }, CreatedReply >({ @@ -1386,6 +1402,12 @@ function ReplyComposer({ isSubmittable: isReplyable, }); + useEffect(() => { + if (attachments.images.length > 0 && gif !== null) { + updateGif(null); + } + }, [attachments.images.length, gif, updateGif]); + const returnFocusToCommit = () => { const commit = textareaRef.current?.closest('[data-commit]'); @@ -1432,8 +1454,9 @@ function ReplyComposer({ request.setData(() => ({ body, - images, - image_descriptions: descriptions, + images: gif === null ? images : [], + image_descriptions: gif === null ? descriptions : [], + ...(gif !== null ? gifFormFields(gif) : {}), commit_rules_accepted: commitRules.hasAccepted(), ...(linkCard.isChosen ? { link_preview_url: linkCard.link ?? '' } @@ -1513,6 +1536,9 @@ function ReplyComposer({ /> )}
+ {preview.isPreviewing && gif !== null && ( + + )} )} + {!preview.isPreviewing && ( + updateGif(null)} + /> + )}
- { + updateGif(null); + return attachments.pick(files); + }} + onPickGif={(picked) => { + attachments.clear(); + updateGif(picked); + }} className="size-8" /> diff --git a/resources/js/components/gif-picker-dialog.tsx b/resources/js/components/gif-picker-dialog.tsx new file mode 100644 index 000000000..bbd348bf9 --- /dev/null +++ b/resources/js/components/gif-picker-dialog.tsx @@ -0,0 +1,269 @@ +import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { attachmentFromResult } from '@/lib/gif-providers'; +import type { + GifAttachment, + GifProvider, + GifResult, +} from '@/lib/gif-providers'; +import { GifRateLimited } from '@/lib/gif-providers'; + +type Status = 'loading' | 'ready' | 'error' | 'rate-limited'; + +export function GifPickerDialog({ + provider, + apiKey, + open, + onOpenChange, + onPick, +}: { + provider: GifProvider; + apiKey: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onPick: (gif: GifAttachment) => void; +}) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [status, setStatus] = useState('loading'); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [reloads, setReloads] = useState(0); + const generation = useRef(0); + const queryRef = useRef(query); + const loadMoreController = useRef(null); + + useEffect(() => { + const currentGeneration = ++generation.current; + queryRef.current = query; + + if (!open) { + return; + } + + setNextCursor(null); + + const controller = new AbortController(); + const timeout = setTimeout( + () => { + setIsLoadingMore(false); + setStatus('loading'); + const trimmed = query.trim(); + const request = + trimmed === '' + ? provider.trending(apiKey, { + cursor: null, + signal: controller.signal, + }) + : provider.search(apiKey, trimmed, { + cursor: null, + signal: controller.signal, + }); + + request + .then((page) => { + if ( + controller.signal.aborted || + currentGeneration !== generation.current + ) { + return; + } + + setResults(page.results); + setNextCursor(page.nextCursor); + setStatus('ready'); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; + } + + setStatus( + error instanceof GifRateLimited + ? 'rate-limited' + : 'error', + ); + }); + }, + query === '' ? 0 : 300, + ); + + return () => { + clearTimeout(timeout); + controller.abort(); + loadMoreController.current?.abort(); + }; + }, [open, query, apiKey, reloads, provider]); + + const loadMore = () => { + const currentGeneration = generation.current; + const requestedQuery = query; + const controller = new AbortController(); + loadMoreController.current = controller; + setIsLoadingMore(true); + const trimmed = query.trim(); + const request = + trimmed === '' + ? provider.trending(apiKey, { + cursor: nextCursor, + signal: controller.signal, + }) + : provider.search(apiKey, trimmed, { + cursor: nextCursor, + signal: controller.signal, + }); + + request + .then((page) => { + if ( + controller.signal.aborted || + currentGeneration !== generation.current || + requestedQuery !== queryRef.current + ) { + return; + } + + setResults((current) => [...current, ...page.results]); + setNextCursor(page.nextCursor); + }) + .catch(() => undefined) + .finally(() => { + if ( + currentGeneration === generation.current && + requestedQuery === queryRef.current + ) { + setIsLoadingMore(false); + } + }); + }; + + const pick = (result: GifResult) => { + onPick(attachmentFromResult(provider.id, result)); + onOpenChange(false); + setQuery(''); + }; + + const loadMoreButton = nextCursor !== null && ( + + ); + + return ( + + + + Attach a GIF + + Search {provider.label} and pick a GIF to attach. + + + setQuery(event.target.value)} + placeholder={provider.searchPlaceholder} + aria-label={provider.searchPlaceholder} + autoComplete="off" + spellCheck={false} + className="h-9 w-full min-w-0 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" + data-test="gif-search-input" + /> + {status === 'loading' && ( +

+ Loading… +

+ )} + {status === 'rate-limited' && ( +

+ You’ve hit your {provider.label} key’s search rate + limit. Try again shortly. +

+ )} + {status === 'error' && ( +
+

+ {provider.label} did not answer. +

+ +
+ )} + {status === 'ready' && results.length === 0 && ( + <> +

+ No GIFs matched. /dev/null remains empty. +

+ {loadMoreButton} + + )} + {status === 'ready' && results.length > 0 && ( + <> +
    + {results.map((result) => + result.previewUrl === null ? null : ( +
  • + +
  • + ), + )} +
+ {loadMoreButton} + + )} + +
+
+ ); +} diff --git a/resources/js/components/issue-command.tsx b/resources/js/components/issue-command.tsx index 56b0513c7..e495ff949 100644 --- a/resources/js/components/issue-command.tsx +++ b/resources/js/components/issue-command.tsx @@ -1,5 +1,5 @@ import { useHttp, usePage } from '@inertiajs/react'; -import { Bug, Lightbulb, X } from 'lucide-react'; +import { Bug, ImagePlay, Lightbulb, X } from 'lucide-react'; import type { KeyboardEvent } from 'react'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -8,7 +8,12 @@ import { showTimeout, timeoutFrom } from '@/components/timeout-dialog'; import { WordmarkText } from '@/components/wordmark-text'; import { collectEnvironment } from '@/lib/environment'; import type { IssueEnvironment } from '@/lib/environment'; -import type { IssueCommand, IssueKind } from '@/lib/issue-commands'; +import type { + IssueCommand, + IssueKind, + SlashCommand, + SlashCommandKind, +} from '@/lib/issue-commands'; import { hintedCommand, issueCommands, @@ -35,12 +40,16 @@ export function useIssueCommand({ body, updateBody, enabled, + canAttachGif = false, + onAttachGif, initialKind = null, onCreated, }: { body: string; updateBody: (body: string) => void; enabled: boolean; + canAttachGif?: boolean; + onAttachGif?: () => void; initialKind?: IssueKind | null; onCreated: () => void; }) { @@ -60,20 +69,30 @@ export function useIssueCommand({ // A draft restored from the stash can be a lone slash: the menu only opens while the user is typing in it. const suggestions = enabled && kind === null && !dismissed && focused - ? suggestedCommands(body) + ? suggestedCommands(body, { canAttachGif }) : []; const hint = enabled && kind === null && !hintDismissed ? hintedCommand(body) : null; - const start = (next: IssueKind, rest: string) => { - setKind(next); + const start = (next: SlashCommandKind, rest: string) => { setSelected(0); + + if (next === 'gif') { + onAttachGif?.(); + updateBody(''); + return; + } + + setKind(next); updateBody(rest); }; const change = (value: string) => { setDismissed(false); - const typed = enabled && kind === null ? typedCommand(value) : null; + const typed = + enabled && kind === null + ? typedCommand(value, { canAttachGif }) + : null; if (typed) { start(typed.kind, typed.body); @@ -225,7 +244,7 @@ export function useIssueCommand({ export type UseIssueCommandReturn = ReturnType; -const icons = { feature: Lightbulb, bug: Bug }; +const icons = { feature: Lightbulb, bug: Bug, gif: ImagePlay }; export function IssueCommandBadge({ command, @@ -264,9 +283,9 @@ export function IssueCommandSuggestions({ selected, onPick, }: { - suggestions: IssueCommand[]; + suggestions: SlashCommand[]; selected: number; - onPick: (kind: IssueKind) => void; + onPick: (kind: SlashCommandKind) => void; }) { return (
void; readonly updateImages: (images: File[], descriptions?: string[]) => void; + readonly updateGif: (gif: GifAttachment | null) => void; readonly drop: () => void; }; const listeners = new Set<() => void>(); const pending = new Map(); const images = new Map(); +const gifs = new Map(); const restoredImageKeys = new Set(); const noImages: StoredImages = { images: [], descriptions: [] }; let timeout: ReturnType | undefined; @@ -44,6 +49,20 @@ const read = (key: string): string => const imagesFor = (key: string): StoredImages => images.get(key) ?? noImages; +const gifFor = (key: string): GifAttachment | null => { + if (!gifs.has(key)) { + gifs.set(key, readStoredGif(key)); + } + + return gifs.get(key) ?? null; +}; + +const storeGif = (key: string, gif: GifAttachment | null): void => { + gifs.set(key, gif); + writeStoredGif(key, gif); + notify(); +}; + const useDraftImages = (key: string) => { const draftImages = useSyncExternalStore( subscribe, @@ -186,6 +205,10 @@ export function clearStashes(userId: string): void { restoredImageKeys.delete(key); }); + [...gifs.keys()] + .filter((key) => key.startsWith(prefix)) + .forEach((key) => gifs.delete(key)); + deleteStoredImages(prefix); notify(); } @@ -200,7 +223,9 @@ export function useHasStash(userId: string | null, name: string): boolean { subscribe, () => key !== null && - (read(key) !== '' || imagesFor(key).images.length > 0), + (read(key) !== '' || + imagesFor(key).images.length > 0 || + gifFor(key) !== null), () => false, ); } @@ -209,8 +234,13 @@ export function useStash(userId: string, name: string): UseStashReturn { const key = keyFor(userId, name); const [body, setBody] = useState(() => read(key)); const { draftImages, descriptions, isImagesRestored } = useDraftImages(key); + const gif = useSyncExternalStore( + subscribe, + () => gifFor(key), + () => null, + ); const [isDraftRestored, setIsDraftRestored] = useState( - () => body !== '' || draftImages.length > 0, + () => body !== '' || draftImages.length > 0 || gifFor(key) !== null, ); const updateBody = (next: string): void => { @@ -237,12 +267,21 @@ export function useStash(userId: string, name: string): UseStashReturn { storeImages(key, next, descriptions); }; + const updateGif = (next: GifAttachment | null): void => { + if (next === null && body === '' && draftImages.length === 0) { + setIsDraftRestored(false); + } + + storeGif(key, next); + }; + const drop = (): void => { cancelImageRead(key); setBody(''); setIsDraftRestored(false); discardBody(key); storeImages(key, []); + storeGif(key, null); }; return { @@ -250,11 +289,13 @@ export function useStash(userId: string, name: string): UseStashReturn { body, images: draftImages, descriptions, + gif, isRestored: (isDraftRestored || isImagesRestored) && - (body !== '' || draftImages.length > 0), + (body !== '' || draftImages.length > 0 || gif !== null), updateBody, updateImages, + updateGif, drop, } as const; } diff --git a/resources/js/lib/gif-provider-key.ts b/resources/js/lib/gif-provider-key.ts new file mode 100644 index 000000000..75bf71255 --- /dev/null +++ b/resources/js/lib/gif-provider-key.ts @@ -0,0 +1,199 @@ +import { useMemo, useSyncExternalStore } from 'react'; +import { gifProviders } from './gif-providers.ts'; +import type { GifAttachment, GifProviderId } from './gif-providers.ts'; +import { readStorage, writeStorage } from './storage.ts'; + +const gifKey = (key: string): string => `${key}:gif`; + +/** A stashed GIF is tiny JSON in localStorage; images are Files in IndexedDB. */ +export function readStoredGif(key: string): GifAttachment | null { + const raw = readStorage(gifKey(key)); + + if (raw === '') { + return null; + } + + try { + const parsed = JSON.parse(raw) as Partial; + + return typeof parsed.id === 'string' && + typeof parsed.url === 'string' && + typeof parsed.pageUrl === 'string' && + typeof parsed.provider === 'string' && + parsed.provider in gifProviders + ? (parsed as GifAttachment) + : null; + } catch { + return null; + } +} + +export function writeStoredGif(key: string, gif: GifAttachment | null): void { + writeStorage(gifKey(key), gif === null ? '' : JSON.stringify(gif)); +} + +export type GifProviderKey = { + provider: GifProviderId; + key: string; +}; + +type Cached = { raw: string; value: GifProviderKey | null }; + +const cache = new Map(); +const listeners = new Map void>>(); + +const storageKey = (userId: string): string => `gif-provider:${userId}`; + +function parse(raw: string): GifProviderKey | null { + if (raw === '') { + return null; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + + const candidate = parsed as { provider?: unknown; key?: unknown }; + + if ( + typeof candidate.provider !== 'string' || + !Object.hasOwn(gifProviders, candidate.provider) || + typeof candidate.key !== 'string' || + candidate.key.trim() === '' || + candidate.key.length > 255 + ) { + return null; + } + + return { + provider: candidate.provider as GifProviderId, + key: candidate.key.trim(), + }; +} + +export function readGifProviderKey(userId: string): GifProviderKey | null { + const raw = readStorage(storageKey(userId)); + const cached = cache.get(userId); + + if (cached !== undefined && cached.raw === raw) { + return cached.value; + } + + const value = parse(raw); + cache.set(userId, { raw, value }); + + return value; +} + +function notify(userId: string): void { + listeners.get(userId)?.forEach((listener) => listener()); +} + +export function writeGifProviderKey( + userId: string, + value: GifProviderKey, +): boolean { + const key = value.key.trim(); + + if (key === '' || key.length > 255) { + return false; + } + + writeStorage( + storageKey(userId), + JSON.stringify({ provider: value.provider, key }), + ); + cache.delete(userId); + + const stored = readGifProviderKey(userId); + + if ( + stored === null || + stored.provider !== value.provider || + stored.key !== key + ) { + return false; + } + + notify(userId); + + return true; +} + +export function clearGifProviderKey(userId: string): void { + writeStorage(storageKey(userId), ''); + cache.delete(userId); + notify(userId); +} + +export function subscribeGifProviderKey( + userId: string, + listener: () => void, +): () => void { + const key = storageKey(userId); + const subscribed = listeners.get(userId) ?? new Set<() => void>(); + + subscribed.add(listener); + listeners.set(userId, subscribed); + + const onStorage = (event: StorageEvent): void => { + if (event.key !== null && event.key !== key) { + return; + } + + if ( + event.storageArea !== undefined && + event.storageArea !== null && + event.storageArea !== window.localStorage + ) { + return; + } + + cache.delete(userId); + listener(); + }; + + if (typeof window !== 'undefined') { + window.addEventListener('storage', onStorage); + } + + return () => { + subscribed.delete(listener); + + if (subscribed.size === 0) { + listeners.delete(userId); + } + + if (typeof window !== 'undefined') { + window.removeEventListener('storage', onStorage); + } + }; +} + +const getServerSnapshot = (): null => null; + +export function useGifProviderKey( + userId: string | null, +): GifProviderKey | null { + const subscribe = useMemo( + () => (listener: () => void) => + userId === null + ? () => undefined + : subscribeGifProviderKey(userId, listener), + [userId], + ); + const getSnapshot = useMemo( + () => () => (userId === null ? null : readGifProviderKey(userId)), + [userId], + ); + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/resources/js/lib/gif-providers.ts b/resources/js/lib/gif-providers.ts new file mode 100644 index 000000000..e93ed01ef --- /dev/null +++ b/resources/js/lib/gif-providers.ts @@ -0,0 +1,391 @@ +import type { CommitGif } from '@/types/commit'; + +export type GifProviderId = 'giphy' | 'klipy'; + +export type GifResult = { + id: string; + url: string; + pageUrl: string; + title: string | null; + username: string | null; + width: number; + height: number; + previewUrl: string | null; +}; + +export type GifAttachment = GifResult & { provider: GifProviderId }; + +export type GifSearchPage = { + results: GifResult[]; + nextCursor: string | null; +}; + +export type GifKeyValidation = + | 'valid' + | 'invalid' + | 'rate-limited' + | 'unreachable'; + +export class GifRateLimited extends Error { + constructor() { + super('The GIF provider is rate limiting searches.'); + } +} + +export interface GifProvider { + readonly id: GifProviderId; + readonly label: string; + readonly searchPlaceholder: string; + readonly attribution: { readonly label: string; readonly href: string }; + search( + key: string, + query: string, + options: { cursor: string | null; signal?: AbortSignal }, + ): Promise; + trending( + key: string, + options: { cursor: string | null; signal?: AbortSignal }, + ): Promise; + validateKey(key: string): Promise; +} + +const blockedTerms = [ + 'porn', + 'pornography', + 'nsfw', + 'xxx', + 'hentai', + 'nudity', + 'nude', + 'naked', + 'erotic', + 'sex', + 'gore', + 'gory', + 'cocaine', + 'heroin', + 'methamphetamine', +]; + +const blockedPattern = new RegExp(`\\b(?:${blockedTerms.join('|')})\\b`, 'i'); + +export function isSafeGiphyRating(rating: string | undefined): boolean { + return rating?.toLowerCase() === 'g'; +} + +export function isBlockedGifText( + ...texts: Array +): boolean { + return texts.some( + (text) => typeof text === 'string' && blockedPattern.test(text), + ); +} + +type GiphyRendition = { url: string; width: string; height: string }; + +type GiphyItem = { + id: string; + title: string; + username: string; + rating?: string; + url: string; + images: Partial< + Record< + | 'downsized_medium' + | 'downsized' + | 'original' + | 'fixed_height_small', + GiphyRendition + > + >; +}; + +const giphyPageSize = 24; +const giphyBaseUrl = 'https://api.giphy.com/v1/gifs'; + +const giphyParams = (key: string, offset: string | null): URLSearchParams => + new URLSearchParams({ + api_key: key, + limit: String(giphyPageSize), + offset: offset ?? '0', + rating: 'g', + }); + +const normalizeGiphy = (item: GiphyItem): GifResult | null => { + if ( + !isSafeGiphyRating(item.rating) || + isBlockedGifText(item.title, item.username) + ) { + return null; + } + + const stored = + item.images.downsized_medium ?? + item.images.downsized ?? + item.images.original; + + if (stored === undefined) { + return null; + } + + const preview = + item.images.fixed_height_small ?? + item.images.downsized ?? + item.images.downsized_medium ?? + item.images.original; + + return { + id: item.id, + url: stored.url, + pageUrl: item.url, + title: item.title === '' ? null : item.title, + username: item.username === '' ? null : item.username, + width: Number.parseInt(stored.width, 10), + height: Number.parseInt(stored.height, 10), + previewUrl: preview?.url ?? null, + }; +}; + +async function giphyPage( + url: string, + options: { cursor: string | null; signal?: AbortSignal }, +): Promise { + const response = await fetch(url, { signal: options.signal }); + + if (response.status === 429) { + throw new GifRateLimited(); + } + + if (!response.ok) { + throw new Error(`GIPHY request failed: ${response.status}`); + } + + const data = (await response.json()) as { data: GiphyItem[] }; + const results = data.data + .map(normalizeGiphy) + .filter((result): result is GifResult => result !== null); + + return { + results, + nextCursor: + data.data.length < giphyPageSize + ? null + : String( + Number.parseInt(options.cursor ?? '0', 10) + + giphyPageSize, + ), + }; +} + +async function validateGiphyKey(key: string): Promise { + const search = new URLSearchParams({ + api_key: key, + limit: '1', + offset: '0', + rating: 'g', + }); + + let response: Response; + + try { + response = await fetch(`${giphyBaseUrl}/trending?${search}`); + } catch { + return 'unreachable'; + } + + if (response.ok) { + return 'valid'; + } + + if (response.status === 401 || response.status === 403) { + return 'invalid'; + } + + return response.status === 429 ? 'rate-limited' : 'unreachable'; +} + +export const giphy: GifProvider = { + id: 'giphy', + label: 'GIPHY', + searchPlaceholder: 'Search GIPHY', + attribution: { label: 'Powered By GIPHY', href: 'https://giphy.com' }, + search: (key, query, options) => { + const search = giphyParams(key, options.cursor); + search.set('q', query); + + return giphyPage(`${giphyBaseUrl}/search?${search}`, options); + }, + trending: (key, options) => + giphyPage( + `${giphyBaseUrl}/trending?${giphyParams(key, options.cursor)}`, + options, + ), + validateKey: validateGiphyKey, +}; + +type KlipyMedia = { url: string; dims: [number, number] }; + +type KlipyItem = { + id: string; + title?: string; + content_description?: string; + itemurl: string; + media_formats?: { gif?: KlipyMedia; tinygif?: KlipyMedia }; +}; + +type KlipyResponse = { results?: KlipyItem[]; next?: string }; + +const klipyPageSize = 24; +const klipyBaseUrl = 'https://api.klipy.com/v2'; + +const klipyParams = (key: string, cursor: string | null): URLSearchParams => { + const search = new URLSearchParams({ + key, + limit: String(klipyPageSize), + contentfilter: 'high', + }); + + if (cursor !== null) { + search.set('pos', cursor); + } + + return search; +}; + +const normalizeKlipy = (item: KlipyItem): GifResult | null => { + if (isBlockedGifText(item.title, item.content_description)) { + return null; + } + + const gif = item.media_formats?.gif; + + if (gif === undefined) { + return null; + } + + return { + id: item.id, + url: gif.url, + pageUrl: item.itemurl, + title: item.title || item.content_description || null, + username: null, + width: gif.dims[0], + height: gif.dims[1], + previewUrl: item.media_formats?.tinygif?.url ?? gif.url, + }; +}; + +async function klipyPage( + url: string, + options: { cursor: string | null; signal?: AbortSignal }, +): Promise { + const response = await fetch(url, { signal: options.signal }); + + if (response.status === 429) { + throw new GifRateLimited(); + } + + if (!response.ok) { + throw new Error(`KLIPY request failed: ${response.status}`); + } + + const data = (await response.json()) as KlipyResponse; + const results = (data.results ?? []) + .map(normalizeKlipy) + .filter((result): result is GifResult => result !== null); + + return { + results, + nextCursor: data.next === '' ? null : (data.next ?? null), + }; +} + +async function validateKlipyKey(key: string): Promise { + const search = new URLSearchParams({ + key, + limit: '8', + contentfilter: 'high', + }); + + let response: Response; + + try { + response = await fetch(`${klipyBaseUrl}/featured?${search}`); + } catch { + return 'unreachable'; + } + + if (response.ok) { + return 'valid'; + } + + if (response.status === 401 || response.status === 403) { + return 'invalid'; + } + + return response.status === 429 ? 'rate-limited' : 'unreachable'; +} + +export const klipy: GifProvider = { + id: 'klipy', + label: 'KLIPY', + searchPlaceholder: 'Search KLIPY', + attribution: { label: 'Powered by KLIPY', href: 'https://klipy.com' }, + search: (key, query, options) => { + const search = klipyParams(key, options.cursor); + search.set('q', query); + + return klipyPage(`${klipyBaseUrl}/search?${search}`, options); + }, + trending: (key, options) => + klipyPage( + `${klipyBaseUrl}/featured?${klipyParams(key, options.cursor)}`, + options, + ), + validateKey: validateKlipyKey, +}; + +export const gifProviders: Record = { + klipy, + giphy, +}; + +export function gifProviderFor(id: GifProviderId): GifProvider { + return gifProviders[id]; +} + +export function attachmentFromResult( + id: GifProviderId, + result: GifResult, +): GifAttachment { + return { ...result, provider: id }; +} + +/** A gif attachment shaped for CommitGif to render, dropping the draft's extra previewUrl field. */ +export function toCommitGif(gif: GifAttachment): CommitGif { + return { + provider: gif.provider, + id: gif.id, + url: gif.url, + page_url: gif.pageUrl, + title: gif.title, + username: gif.username, + width: gif.width, + height: gif.height, + }; +} + +export function gifFormFields( + gif: GifAttachment, +): Record { + return { + gif_provider: gif.provider, + gif_id: gif.id, + gif_url: gif.url, + gif_page_url: gif.pageUrl, + gif_title: gif.title ?? '', + gif_username: gif.username ?? '', + gif_width: gif.width, + gif_height: gif.height, + }; +} diff --git a/resources/js/lib/issue-commands.ts b/resources/js/lib/issue-commands.ts index 81947133b..50c76bf98 100644 --- a/resources/js/lib/issue-commands.ts +++ b/resources/js/lib/issue-commands.ts @@ -1,12 +1,25 @@ export type IssueKind = 'feature' | 'bug'; -export type IssueCommand = { - kind: IssueKind; +type CommandShape = { name: string; description: string; +}; + +export type IssueCommand = CommandShape & { + kind: IssueKind; placeholder: string; }; +export type GifCommand = CommandShape & { + kind: 'gif'; +}; + +export type SlashCommand = IssueCommand | GifCommand; + +export type SlashCommandKind = SlashCommand['kind']; + +type CommandOptions = { canAttachGif?: boolean }; + export const issueCommands: IssueCommand[] = [ { kind: 'feature', @@ -24,21 +37,44 @@ export const issueCommands: IssueCommand[] = [ }, ]; +export const gifCommand: GifCommand = { + kind: 'gif', + name: '/gif', + description: 'Attach a GIF', +}; + +function availableCommands({ + canAttachGif = false, +}: CommandOptions): SlashCommand[] { + return canAttachGif ? [...issueCommands, gifCommand] : issueCommands; +} + /** * The commands a draft could still become while it is only a slash and the start of a name. */ -export function suggestedCommands(body: string): IssueCommand[] { +export function suggestedCommands( + body: string, + options: CommandOptions = {}, +): SlashCommand[] { return /^\/[a-z]*$/.test(body) - ? issueCommands.filter((command) => command.name.startsWith(body)) + ? availableCommands(options).filter((command) => + command.name.startsWith(body), + ) : []; } /** - * A complete command followed by a space or a new line turns the draft into an issue, without the command. + * A complete command followed by a space or a new line. Issue commands keep the rest of the draft as their body; + * `/gif` only matches on its own so it can never discard text. */ export function typedCommand( body: string, -): { kind: IssueKind; body: string } | null { + { canAttachGif = false }: CommandOptions = {}, +): { kind: SlashCommandKind; body: string } | null { + if (canAttachGif && /^\/gif\s+$/.test(body)) { + return { kind: 'gif', body: '' }; + } + const match = /^\/(feature|bug)\s/.exec(body); return match diff --git a/resources/js/pages/user-profile/edit.tsx b/resources/js/pages/user-profile/edit.tsx index b771cdfe2..5be159849 100644 --- a/resources/js/pages/user-profile/edit.tsx +++ b/resources/js/pages/user-profile/edit.tsx @@ -1,5 +1,6 @@ import { Form, Head, Link, router } from '@inertiajs/react'; import { ArrowUpRight, Download, RefreshCw } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import ExportController from '@/actions/App/Http/Controllers/ExportController'; import UserProfileController from '@/actions/App/Http/Controllers/UserProfileController'; import ActivityGraphEmbed from '@/components/activity-graph-embed'; @@ -12,11 +13,19 @@ import { RemotesSetting } from '@/components/remotes-setting'; import TimezonePicker from '@/components/timezone-picker'; import TranslationLanguageSetting from '@/components/translation-language-setting'; import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import { useNow } from '@/hooks/use-now'; import { useUser } from '@/hooks/use-user'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; +import { + clearGifProviderKey, + useGifProviderKey, + writeGifProviderKey, +} from '@/lib/gif-provider-key'; +import { gifProviderFor, gifProviders } from '@/lib/gif-providers'; +import type { GifProviderId } from '@/lib/gif-providers'; import type { TranslationLanguage } from '@/lib/translation-languages'; import { formatFullDateTime, formatSyncedAgo } from '@/lib/utils'; import { show } from '@/routes/user'; @@ -122,6 +131,8 @@ export default function Edit({ + + = { + giphy: 'https://developers.giphy.com', + klipy: 'https://partner.klipy.com', +}; + +export function GifKeySetting() { + const { user } = useUser(); + const entry = useGifProviderKey(user.id); + const [providerId, setProviderId] = useState( + entry?.provider ?? 'klipy', + ); + const [key, setKey] = useState(''); + const [editing, setEditing] = useState(false); + const [processing, setProcessing] = useState(false); + const [error, setError] = useState(null); + const displayProviderId: GifProviderId = + editing || entry === null ? providerId : entry.provider; + const provider = gifProviderFor(displayProviderId); + const lastStored = useRef<{ provider?: GifProviderId; key?: string }>({ + provider: entry?.provider, + key: entry?.key, + }); + + useEffect(() => { + if ( + lastStored.current.provider !== entry?.provider || + lastStored.current.key !== entry?.key + ) { + lastStored.current = { provider: entry?.provider, key: entry?.key }; + setError(null); + } + }, [entry?.provider, entry?.key]); + + const connect = async () => { + if (processing) { + return; + } + + const candidate = key.trim(); + + if (candidate === '') { + return; + } + + if (candidate.length > 255) { + setError('That key is too long.'); + + return; + } + + setProcessing(true); + setError(null); + + const selected = gifProviderFor(providerId); + const verdict = await selected.validateKey(candidate); + + if (verdict === 'invalid') { + setError(`That key was rejected by ${selected.label}.`); + setProcessing(false); + + return; + } + + if (verdict === 'unreachable') { + setError(`Couldn't reach ${selected.label} to verify the key.`); + setProcessing(false); + + return; + } + + if ( + !writeGifProviderKey(user.id, { + provider: providerId, + key: candidate, + }) + ) { + setError("Couldn't save the key in this browser."); + setProcessing(false); + + return; + } + + setKey(''); + setError(null); + setEditing(false); + setProcessing(false); + }; + + const remove = () => { + clearGifProviderKey(user.id); + setError(null); + setEditing(false); + }; + + return ( +
+

+ GIFs +

+

+ GIF search runs on your own {provider.label} API key, under your + own quota. Get a key at{' '} + + {provider.label} + {' '} + and save it here. Without a key, the picker stays hidden. +

+

+ Stored in this browser only — never sent to the server. Each + device needs its own key. +

+ {entry !== null && !editing ? ( +
+

+ Connected to {gifProviderFor(entry.provider).label}. +

+ + +
+ ) : ( +
+
+ {Object.values(gifProviders).map((candidate) => ( + + ))} +
+ +
+ setKey(event.target.value)} + placeholder={`Paste your ${provider.label} API key`} + disabled={processing} + autoComplete="off" + spellCheck={false} + className="h-9 min-w-0 flex-1 rounded-md border bg-card px-3 text-base outline-none placeholder:text-muted-foreground focus-visible:border-primary md:text-sm" + data-test="gif-key-input" + /> + +
+
+ )} + {error !== null && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts index 82f791fb8..ff09cfd21 100644 --- a/resources/js/types/commit.ts +++ b/resources/js/types/commit.ts @@ -1,3 +1,4 @@ +import type { GifProviderId } from '@/lib/gif-providers'; import type { User } from '@/types/auth'; export type CommitStatus = 'pending' | 'approved' | 'rejected'; @@ -47,6 +48,17 @@ export type CommitImage = { description: string | null; }; +export type CommitGif = { + provider: GifProviderId; + id: string; + url: string; + page_url: string; + title: string | null; + username: string | null; + width: number; + height: number; +}; + export type BodySegment = | { type: 'text'; text: string } | { type: 'code'; code: string } @@ -100,6 +112,7 @@ export type Commit = { quoted_commit_id: string | null; quoted_commit?: QuotedCommit | DeletedCommit | null; images?: CommitImage[]; + gif?: CommitGif | null; ai_decision?: Exclude | null; ai_categories?: string[] | null; }; diff --git a/tests/Feature/Controllers/Api/CommitControllerTest.php b/tests/Feature/Controllers/Api/CommitControllerTest.php index 0b53367a0..72f2c81f1 100644 --- a/tests/Feature/Controllers/Api/CommitControllerTest.php +++ b/tests/Feature/Controllers/Api/CommitControllerTest.php @@ -43,6 +43,7 @@ 'replies_count' => 0, 'link_preview' => null, 'images' => [], + 'gif' => null, 'author' => [ 'id' => $user->id, 'handle' => 'octocat', @@ -202,6 +203,25 @@ $this->getJson(route('api-commit.show', $commit), ['REMOTE_ADDR' => '10.0.0.2'])->assertOk(); }); +it('includes the gif of a commit', function (): void { + $commit = Commit::factory()->withGif()->create(); + + $this->getJson("/api/v1/commits/{$commit->id}") + ->assertOk() + ->assertJsonPath('data.gif.provider', 'giphy') + ->assertJsonPath('data.gif.id', 'xT9IgzoKnwFNmISR8I') + ->assertJsonPath('data.gif.url', 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123') + ->assertJsonPath('data.gif.width', 480); +}); + +it('includes a null gif for a plain commit', function (): void { + $commit = Commit::factory()->create(); + + $this->getJson("/api/v1/commits/{$commit->id}") + ->assertOk() + ->assertJsonPath('data.gif', null); +}); + it('shows a signed request the commit as its page shows it to its user', function (): void { $user = User::factory()->create(['handle' => 'octocat']); $keyPair = apiKeyPairFor($user); @@ -249,6 +269,7 @@ 'images' => [ ['id' => $image->id, 'width' => 1200, 'height' => 800, 'description' => null, 'url' => route('commit-image.show', $image)], ], + 'gif' => null, 'author' => $author($user), 'body_segments' => [ ['type' => 'text', 'text' => 'Ask @nunomaduro about '], @@ -275,6 +296,7 @@ 'stars_count' => 0, 'link_preview' => null, 'images' => [], + 'gif' => null, 'author' => $author($root->user), 'body_segments' => [['type' => 'text', 'text' => 'Who broke prod?']], 'preview_segments' => null, diff --git a/tests/Feature/Controllers/Api/LogControllerTest.php b/tests/Feature/Controllers/Api/LogControllerTest.php index 511d631b5..781276f86 100644 --- a/tests/Feature/Controllers/Api/LogControllerTest.php +++ b/tests/Feature/Controllers/Api/LogControllerTest.php @@ -57,6 +57,7 @@ 'replies_count' => 2, 'link_preview' => null, 'images' => [], + 'gif' => null, 'author' => [ 'id' => $author->id, 'handle' => 'octocat', diff --git a/tests/Feature/Controllers/Api/NotificationControllerTest.php b/tests/Feature/Controllers/Api/NotificationControllerTest.php index 90df36211..84ea1a984 100644 --- a/tests/Feature/Controllers/Api/NotificationControllerTest.php +++ b/tests/Feature/Controllers/Api/NotificationControllerTest.php @@ -63,6 +63,7 @@ 'parent_id' => $reply->parent_id, 'html_url' => route('commit.show', [$replied->actor, $reply]), 'created_at' => now()->toIso8601ZuluString(), + 'gif' => null, 'author' => $user($replied->actor), 'body_segments' => [['type' => 'text', 'text' => $reply->body]], 'preview_segments' => null, @@ -78,6 +79,7 @@ 'parent_id' => null, 'html_url' => route('commit.show', [$this->user, $reply->parent]), 'created_at' => now()->toIso8601ZuluString(), + 'gif' => null, 'author' => $user($this->user), 'body_segments' => [['type' => 'text', 'text' => $reply->parent->body]], 'preview_segments' => null, diff --git a/tests/Feature/Controllers/CommitControllerTest.php b/tests/Feature/Controllers/CommitControllerTest.php index 5809dd842..e9a523467 100644 --- a/tests/Feature/Controllers/CommitControllerTest.php +++ b/tests/Feature/Controllers/CommitControllerTest.php @@ -1040,6 +1040,31 @@ ->and(mb_strpos($content, $second->url()))->toBeLessThan(mb_strpos($content, route('commit-open-graph-image.show', [$user, $commit]))); }); +it('uses the attached gif as an open graph image', function (): void { + $user = User::factory()->create(['handle' => 'octocat', 'name' => 'The Octocat']); + $commit = Commit::factory()->for($user)->withGif()->create(['body' => 'Deal with it']); + + $content = (string) $this->get(route('commit.show', [$user, $commit]))->assertOk()->getContent(); + + expect($content) + ->toContain(' + + + + ') + ->toContain('') + ->toContain('"image":["https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123","'.route('commit-open-graph-image.show', [$user, $commit]).'?v=') + ->and(mb_strpos($content, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123'))->toBeLessThan(mb_strpos($content, route('commit-open-graph-image.show', [$user, $commit]))); +}); + +it('does not use a gif as an open graph image without an attached gif', function (): void { + $commit = Commit::factory()->create(); + + $content = (string) $this->get(route('commit.show', [$commit->user, $commit]))->assertOk()->getContent(); + + expect($content)->not->toContain('image/gif'); +}); + it('previews the drawn card of a commit without images', function (): void { $commit = Commit::factory()->create(); @@ -1498,3 +1523,192 @@ ->has('replies.data.0.images', 1) ->missing('commit.images.0.path')); }); + +it('may attach a giphy gif to a commit', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Shipping it', + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_title' => 'Deal With It GIF', + 'gif_username' => 'giphy', + 'gif_width' => 480, + 'gif_height' => 270, + ]); + + $response->assertSessionHasNoErrors(); + + $commit = $user->commits()->sole(); + expect($commit->gif?->provider_gif_id)->toBe('xT9IgzoKnwFNmISR8I') + ->and($commit->gif?->url)->toBe('https://media2.giphy.com/media/v1.Y2lkPTc5MGI3NjEx/giphy-downsized-medium.gif?cid=abc123&rid=giphy.gif') + ->and($commit->gif?->width)->toBe(480); +}); + +it('accepts a klipy gif', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Klipy time', + 'gif_provider' => 'klipy', + 'gif_id' => 'hello-hi-662', + 'gif_url' => 'https://static.klipy.com/ii/935d7ab9/8GCrVAB7.gif', + 'gif_page_url' => 'https://klipy.com/gifs/hello-hi-662', + 'gif_width' => 498, + 'gif_height' => 498, + ]); + + $response->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->gif?->provider->value)->toBe('klipy'); +}); + +it('rejects a gif url from another provider', function (): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson(route('commit.store'), [ + 'body' => 'Mismatch', + 'gif_provider' => 'klipy', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://klipy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_url'); +}); + +it('rejects invalid providers', function (array $payload): void { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->postJson(route('commit.store'), $payload); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); +})->with([ + 'missing provider' => [fn (): array => [ + 'body' => 'No provider', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]], + 'non-string provider' => [fn (): array => [ + 'body' => 'Array provider', + 'gif_provider' => ['giphy'], + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]], + 'unknown provider string' => [fn (): array => [ + 'body' => 'Unknown provider', + 'gif_provider' => 'other', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]], +]); + +it('rejects a gif media url that is not on giphy', function (string $url): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Nice try', + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => $url, + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasErrors('gif_url'); + + expect($user->commits()->count())->toBe(0); +})->with([ + 'another host' => ['https://evil.example/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'lookalike host' => ['https://media2.giphy.com.evil.example/media/x/giphy.gif'], + 'plain http' => ['http://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'not a gif rendition' => ['https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.webp'], + 'with a fragment' => ['https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif#frag'], + 'with userinfo' => ['https://user@media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'with a port' => ['https://media2.giphy.com:8443/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'dot-segment traversal' => ['https://media2.giphy.com/media/../avatars/evil.gif'], + 'single-dot segment' => ['https://media2.giphy.com/media/./xT9IgzoKnwFNmISR8I/giphy.gif'], +]); + +it('rejects a partial gif submission', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Half a gif', + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + ])->assertSessionHasErrors(['gif_url', 'gif_page_url', 'gif_width', 'gif_height']); +}); + +it('rejects a gif alongside uploaded images', function (): void { + Storage::fake(); + + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Both at once', + 'images' => [UploadedFile::fake()->image('cabin.jpg', 40, 30)], + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasErrors('gif_id'); +}); + +it('rejects a gif alongside a poll', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Gif or poll', + 'poll' => ['options' => ['Yes', 'No'], 'duration' => 1440], + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasErrors('gif_id'); +}); + +it('may quote a commit with a gif attached', function (): void { + $user = User::factory()->create(); + $quoted = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit.store'), [ + 'body' => 'Quoting with style', + 'quoted_commit_id' => $quoted->id, + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->quoted_commit_id)->toBe($quoted->id); +}); + +it('shows the gif in the commit payload', function (): void { + $commit = Commit::factory()->withGif()->create(); + + $this->actingAs($commit->user) + ->get(route('commit.show', [$commit->user, $commit])) + ->assertInertia(fn ($page) => $page + ->where('commit.gif.id', 'xT9IgzoKnwFNmISR8I') + ->where('commit.gif.url', 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123') + ->missing('commit.gif_url')); +}); diff --git a/tests/Feature/Controllers/CommitReplyControllerTest.php b/tests/Feature/Controllers/CommitReplyControllerTest.php index 476c098fa..82cede419 100644 --- a/tests/Feature/Controllers/CommitReplyControllerTest.php +++ b/tests/Feature/Controllers/CommitReplyControllerTest.php @@ -952,6 +952,141 @@ expect($second->json('data.*.id'))->toBe(array_slice($expected, 1)); }); +it('may attach a giphy gif to a reply', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $response = $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Replying with a gif', + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ]); + + $response->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->gif?->provider_gif_id)->toBe('xT9IgzoKnwFNmISR8I'); +}); + +it('accepts a klipy gif reply', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $response = $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Klipy time', + 'gif_provider' => 'klipy', + 'gif_id' => 'hello-hi-662', + 'gif_url' => 'https://static.klipy.com/ii/935d7ab9/8GCrVAB7.gif', + 'gif_page_url' => 'https://klipy.com/gifs/hello-hi-662', + 'gif_width' => 498, + 'gif_height' => 498, + ]); + + $response->assertSessionHasNoErrors(); + + expect($user->commits()->sole()->gif?->provider->value)->toBe('klipy'); +}); + +it('rejects invalid providers', function (array $payload): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $response = $this->actingAs($user)->postJson(route('commit-reply.store', $parent), $payload); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_provider'); +})->with([ + 'missing provider' => [fn (): array => [ + 'body' => 'Reply without a provider', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]], + 'non-string provider' => [fn (): array => [ + 'body' => 'Reply with an array provider', + 'gif_provider' => ['giphy'], + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]], + 'unknown provider string' => [fn (): array => [ + 'body' => 'Reply with an unknown provider', + 'gif_provider' => 'other', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]], +]); + +it('rejects a reply gif url from another provider', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $response = $this->actingAs($user)->postJson(route('commit-reply.store', $parent), [ + 'body' => 'Mismatch', + 'gif_provider' => 'klipy', + 'gif_id' => 'abc123', + 'gif_url' => 'https://media2.giphy.com/media/abc123/giphy.gif', + 'gif_page_url' => 'https://klipy.com/gifs/abc123', + 'gif_width' => 480, + 'gif_height' => 270, + ]); + + $response->assertUnprocessable()->assertJsonValidationErrors('gif_url'); +}); + +it('rejects a partial gif submission on a reply', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Half a gif', + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + ])->assertSessionHasErrors(['gif_url', 'gif_page_url', 'gif_width', 'gif_height']); +}); + +it('rejects a reply gif alongside uploaded images', function (): void { + Storage::fake(); + + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Both at once', + 'images' => [UploadedFile::fake()->image('cabin.jpg', 40, 30)], + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasErrors('gif_id'); +}); + +it('rejects a bad gif media url on a reply', function (): void { + $user = User::factory()->create(); + $parent = Commit::factory()->create(); + + $this->actingAs($user)->post(route('commit-reply.store', $parent), [ + 'body' => 'Nice try', + 'gif_provider' => 'giphy', + 'gif_id' => 'xT9IgzoKnwFNmISR8I', + 'gif_url' => 'https://evil.example/media/xT9IgzoKnwFNmISR8I/giphy.gif', + 'gif_page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'gif_width' => 480, + 'gif_height' => 270, + ])->assertSessionHasErrors('gif_url'); +}); + it('lists replies in pages of any size up to 50', function (int $limit, int $status): void { $parent = Commit::factory()->create(); Commit::factory()->for($parent, 'parent')->count(4)->create(); diff --git a/tests/Feature/Controllers/UserFeedControllerTest.php b/tests/Feature/Controllers/UserFeedControllerTest.php index d78ae3d35..75decd4a6 100644 --- a/tests/Feature/Controllers/UserFeedControllerTest.php +++ b/tests/Feature/Controllers/UserFeedControllerTest.php @@ -211,3 +211,16 @@ function atomFeed(TestResponse $response): SimpleXMLElement it('returns not found for unknown handles', function (): void { $this->get('/@missing/feed.atom')->assertNotFound(); }); + +it('lists an attached gif as an enclosure', function (): void { + $user = User::factory()->create(); + Commit::factory()->for($user)->withGif()->create(); + + $response = $this->get(route('user-feed.show', $user)); + + $response->assertOk(); + expect($response->getContent()) + ->toContain('rel="enclosure"') + ->toContain('type="image/gif"') + ->toContain('https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123'); +}); diff --git a/tests/Pest.php b/tests/Pest.php index ac43ed690..3083021e6 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -33,6 +33,8 @@ Process::preventStrayProcesses(); Sleep::fake(); + config(['services.github.repository' => 'dev-null-sh/dev-null.sh']); + $this->freezeTime(); }) ->in('Feature', 'Unit'); diff --git a/tests/Unit/CommitGif.test.tsx b/tests/Unit/CommitGif.test.tsx new file mode 100644 index 000000000..5e0f26726 --- /dev/null +++ b/tests/Unit/CommitGif.test.tsx @@ -0,0 +1,100 @@ +/// +import assert from 'node:assert/strict'; +import type { ReactElement, SyntheticEvent } from 'react'; +import { test } from 'vite-plus/test'; +import { CommitGif } from '../../resources/js/components/commit-gif'; +import type { CommitGif as CommitGifData } from '../../resources/js/types/commit'; + +const gif: CommitGifData = { + provider: 'giphy', + id: 'xT9IgzoKnwFNmISR8I', + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + page_url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + width: 480, + height: 270, +}; + +function renderedGif(overrides: Partial = {}) { + const figure = CommitGif({ gif: { ...gif, ...overrides } }); + const [image, attribution] = figure.props.children as [ + ReactElement<{ + src: string; + alt: string; + loading: string; + referrerPolicy: string; + onError: (event: SyntheticEvent) => void; + }>, + ReactElement<{ href: string; target: string; rel: string; children: string }>, + ]; + + return { figure, image, attribution }; +} + +test('hotlinks the giphy url verbatim with its query string intact', () => { + const { image } = renderedGif(); + + assert.equal( + image.props.src, + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + ); + assert.equal(image.props.alt, 'Deal With It GIF'); + assert.equal(image.props.loading, 'lazy'); + assert.equal(image.props.referrerPolicy, 'no-referrer'); +}); + +test('falls back to a generic alt when giphy has no title', () => { + const { image } = renderedGif({ title: null }); + + assert.equal(image.props.alt, 'Attached GIF'); +}); + +test('credits the giphy uploader with a link to the gif page', () => { + const { attribution } = renderedGif(); + + assert.equal(attribution.props.href, gif.page_url); + assert.equal(attribution.props.target, '_blank'); + assert.equal(attribution.props.rel, 'noopener noreferrer'); + assert.equal(attribution.props.children, 'GIF by @giphy on GIPHY'); +}); + +test('falls back to the plain giphy credit when the uploader is unknown', () => { + const { attribution } = renderedGif({ username: null }); + + assert.equal(attribution.props.children, 'via GIPHY'); +}); + +test('credits klipy without a giphy-style username', () => { + const { attribution } = renderedGif({ provider: 'klipy', username: null }); + + assert.equal(attribution.props.children, 'via KLIPY'); +}); + +test('replaces a failed gif with the site logo without retrying the fallback', () => { + let replacements = 0; + let source = gif.url; + const target = { + getAttribute: () => source, + set src(value: string) { + source = value; + replacements++; + }, + classList: { + replace: (from: string, to: string) => { + assert.equal(from, 'object-cover'); + assert.equal(to, 'object-contain'); + }, + }, + }; + const event = { + currentTarget: target, + } as unknown as SyntheticEvent; + const { onError } = renderedGif().image.props; + + onError(event); + assert.equal(source, '/favicon.svg'); + + onError(event); + assert.equal(replacements, 1); +}); diff --git a/tests/Unit/ComposerDraft.test.tsx b/tests/Unit/ComposerDraft.test.tsx index f786609a5..109f6b86e 100644 --- a/tests/Unit/ComposerDraft.test.tsx +++ b/tests/Unit/ComposerDraft.test.tsx @@ -3,8 +3,23 @@ import assert from 'node:assert/strict'; import * as React from 'react'; import type { ReactElement, ReactNode } from 'react'; import { beforeEach, test, vi } from 'vite-plus/test'; +import { cancelImageReads } from '../../resources/js/lib/draft-image-reads'; +import type { GifAttachment } from '../../resources/js/lib/gif-providers'; +import type { GifProviderKey } from '../../resources/js/lib/gif-provider-key'; const image = new File(['restored'], 'draft.png', { type: 'image/png' }); +const gif: GifAttachment = { + provider: 'giphy', + id: 'xT9IgzoKnwFNmISR8I', + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', + pageUrl: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + width: 480, + height: 270, + previewUrl: + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', +}; let state: unknown[] = []; let cursor = 0; const draft = { @@ -12,12 +27,16 @@ const draft = { body: 'A restored draft https://example.com', images: [] as File[], descriptions: [] as string[], + gif: null as GifAttachment | null, isRestored: true, updateBody: vi.fn(), updateImages: vi.fn((images: File[], descriptions: string[]) => { draft.images = images; draft.descriptions = descriptions; }), + updateGif: vi.fn((picked: GifAttachment | null) => { + draft.gif = picked; + }), drop: vi.fn(), }; const user = { id: 'viewer', handle: 'viewer', lane: 1 }; @@ -62,7 +81,13 @@ vi.doMock('react', () => ({ })); vi.doMock('@inertiajs/react', async () => ({ ...(await vi.importActual('@inertiajs/react')), - usePage: () => ({ props: { auth: { user, isAdmin: false } }, flash }), + usePage: () => ({ + props: { + auth: { user, isAdmin: false }, + gif: { provider: 'giphy', key: 'test' }, + }, + flash, + }), useHttp: () => http, })); vi.doMock('../../resources/js/hooks/use-user', () => ({ @@ -73,13 +98,21 @@ vi.doMock('../../resources/js/hooks/use-stash', () => ({ moveCaretToEnd: vi.fn(), discardSeriesStash: vi.fn(), })); +let gifKey: GifProviderKey | null = null; +vi.doMock('../../resources/js/lib/gif-provider-key', () => ({ + useGifProviderKey: () => gifKey, +})); const { CommitComposer } = await import('../../resources/js/components/commit-composer'); +const { default: InputError } = + await import('../../resources/js/components/input-error'); const { IssueCommandBadge, IssueCommandHint, IssueCommandSuggestions } = await import('../../resources/js/components/issue-command'); const { ImageDropOverlay, ImagePickerButton, ImagePickerPreviews } = await import('../../resources/js/components/commit-image-picker'); +const { ComposerMediaButton } = + await import('../../resources/js/components/commit-image-picker'); const { ComposerPreviewBody, PreviewToggle } = await import('../../resources/js/components/composer-preview'); const { MentionTextarea } = @@ -110,23 +143,24 @@ function elements(node: ReactNode): Element[] { ]; } -function render() { +function render(errors: Record = {}) { cursor = 0; const form = CommitComposer({}); - const fields = elements( - form.props.children({ processing: false, errors: {} }), - ); + const fields = elements(form.props.children({ processing: false, errors })); return { form, fields }; } beforeEach(() => { vi.clearAllMocks(); + cancelImageReads(''); state = []; flash = {}; effects = []; draft.body = 'A restored draft https://example.com'; draft.images = []; draft.descriptions = []; + draft.gif = null; + gifKey = null; }); test('preserves restored images and link cards and allows scheduling them', () => { @@ -148,7 +182,7 @@ test('preserves restored images and link cards and allows scheduling them', () = )!; assert.deepEqual(preview.props.images, [image]); const picker = fields.find( - (element) => element.type === ImagePickerButton, + (element) => element.type === ComposerMediaButton, )!; assert.equal(picker.props.count, 1); const schedule = fields.find( @@ -181,6 +215,27 @@ test('preserves restored images and link cards and allows scheduling them', () = } }); +test('surfaces gif validation errors and ignores unrelated ones', () => { + const gifError = render({ gif_url: 'The GIF could not be attached.' }); + assert.ok( + gifError.fields.some( + (element) => + element.type === InputError && + element.props.message === 'The GIF could not be attached.', + ), + ); + + const unrelated = render({ unrelated_field: 'Not an attachment error.' }); + assert.equal( + unrelated.fields.some( + (element) => + element.type === InputError && + element.props.message === 'Not an attachment error.', + ), + false, + ); +}); + test('drops a restored draft that the reloaded page says was already committed', () => { draft.body = 'Shipped it :rocket:\n\n\n\nwith spaces kept'; flash = { @@ -231,7 +286,7 @@ test('removing restored images enables polls and successful posting resets the p )!; assert.equal(submit().props.disabled, true); assert.equal( - view.fields.find((element) => element.type === ImagePickerButton)! + view.fields.find((element) => element.type === ComposerMediaButton)! .props.disabled, true, ); @@ -356,7 +411,7 @@ test('schedules in UTC with pasted images and the chosen link card', () => { scheduled_at: scheduledAt, }); assert.equal( - view.fields.some((element) => element.type === ImagePickerButton), + view.fields.some((element) => element.type === ComposerMediaButton), true, ); assert.equal( @@ -448,6 +503,135 @@ test('previews restored images and descriptions while keeping them attached when }); }); +test('picking a gif clears attached images and any poll and submits the gif fields', () => { + draft.images = [image]; + draft.descriptions = ['A restored screenshot']; + let view = render(); + vi.stubGlobal('requestAnimationFrame', vi.fn()); + try { + ( + view.fields.find( + (element) => element.props['data-test'] === 'add-poll-button', + )!.props.onClick as () => void + )(); + view = render(); + assert.equal( + view.fields.some((element) => element.type === PollEditor), + true, + ); + const gifButton = view.fields.find( + (element) => element.type === ComposerMediaButton, + )!; + (gifButton.props.onPickGif as (picked: GifAttachment) => void)(gif); + assert.deepEqual(draft.images, []); + assert.deepEqual(draft.descriptions, []); + assert.equal(draft.gif, gif); + view = render(); + assert.equal( + view.fields.some((element) => element.type === PollEditor), + false, + ); + assert.deepEqual(view.form.props.transform({ body: draft.body }), { + body: draft.body, + commit_rules_accepted: true, + gif_provider: 'giphy', + gif_id: 'xT9IgzoKnwFNmISR8I', + gif_url: + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', + gif_page_url: + 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + gif_title: 'Deal With It GIF', + gif_username: 'giphy', + gif_width: 480, + gif_height: 270, + }); + } finally { + vi.unstubAllGlobals(); + } +}); + +test('attaching images while a gif is stashed clears the gif and submits the images instead', () => { + draft.updateGif(gif); + let view = render(); + const schedule = view.fields.find( + (element) => element.props['aria-controls'] === 'body-schedule', + )!; + assert.equal(schedule.props.disabled, true); + assert.equal( + view.fields.some( + (element) => + element.props.label === + "A commit with a GIF can't be scheduled yet", + ), + true, + ); + const picked = ( + view.fields.find((element) => element.type === ComposerMediaButton)! + .props.onPickImages as (files: File[]) => Promise + )([image]); + picked.catch(() => undefined); + assert.equal(draft.gif, null); + + // The provider finishes reading the picked file asynchronously. + cancelImageReads(''); + draft.images = [image]; + draft.descriptions = ['']; + view = render(); + assert.deepEqual(view.form.props.transform({ body: draft.body }), { + body: draft.body, + commit_rules_accepted: true, + images: [image], + image_descriptions: [''], + }); +}); + +test('a stashed gif never survives alongside restored images', () => { + draft.images = [image]; + draft.descriptions = ['A restored screenshot']; + draft.updateGif(gif); + const view = render(); + effects.forEach((effect) => effect()); + assert.deepEqual(draft.updateGif.mock.lastCall, [null]); + assert.equal(draft.gif, null); + assert.deepEqual(view.form.props.transform({ body: draft.body }), { + body: draft.body, + commit_rules_accepted: true, + images: [image], + image_descriptions: ['A restored screenshot'], + }); +}); + +test('a gif stashed while scheduling blocks submit and never reaches the transform', () => { + let view = render(); + ( + view.fields.find( + (element) => element.props['aria-controls'] === 'body-schedule', + )!.props.onClick as () => void + )(); + view = render(); + const date = '2026-10-17T10:30'; + ( + view.fields.find((element) => element.props.id === 'body-scheduled-at')! + .props.onChange as (value: string) => void + )(date); + view = render(); + draft.updateGif(gif); + view = render(); + assert.equal( + view.fields.find( + (element) => element.props['data-test'] === 'create-commit-button', + )!.props.disabled, + true, + ); + assert.deepEqual(view.form.props.transform({ body: draft.body }), { + body: draft.body, + commit_rules_accepted: true, + scheduled_at: new Date(date) + .toISOString() + .replace(/\.\d{3}Z$/, '+00:00'), + }); +}); + function keyDown( view: ReturnType, key: string, @@ -531,7 +715,7 @@ test('typing a command and a space turns the draft into an issue, and backspace assert.ok(find('data-test', 'open-issue-button')); assert.equal(find('data-test', 'create-commit-button'), undefined); assert.equal( - view.fields.some((element) => element.type === ImagePickerButton), + view.fields.some((element) => element.type === ComposerMediaButton), false, ); assert.equal(find('data-test', 'add-poll-button'), undefined); @@ -551,6 +735,57 @@ test('typing a command and a space turns the draft into an issue, and backspace assert.equal(view.form.props.onBefore(), true); }); +test('a lone /gif opens the GIF picker without becoming an issue draft', () => { + gifKey = { provider: 'giphy', key: 'test-key' }; + draft.body = '/'; + focus(render()); + const view = render(); + const suggestions = view.fields.find( + (element) => element.type === IssueCommandSuggestions, + )!; + assert.deepEqual( + (suggestions.props.suggestions as { name: string }[]).map( + (command) => command.name, + ), + ['/feature', '/bug', '/gif'], + ); + + const textarea = view.fields.find( + (element) => element.type === MentionTextarea, + )!; + (textarea.props.onValueChange as (value: string) => void)('/gif '); + + assert.deepEqual(draft.updateBody.mock.calls.at(-1), ['']); + + const next = render(); + assert.equal( + next.fields.find((element) => element.type === ComposerMediaButton)! + .props.gifOpen, + true, + ); + assert.equal( + next.fields.some((element) => element.type === IssueCommandBadge), + false, + ); +}); + +test('a stashed gif removes /gif from the slash suggestions', () => { + gifKey = { provider: 'giphy', key: 'test-key' }; + draft.gif = gif; + draft.body = '/'; + focus(render()); + const view = render(); + const suggestions = view.fields.find( + (element) => element.type === IssueCommandSuggestions, + )!; + assert.deepEqual( + (suggestions.props.suggestions as { name: string }[]).map( + (command) => command.name, + ), + ['/feature', '/bug'], + ); +}); + test('picking a suggestion with Enter starts an empty issue, and the shortcut opens it instead of committing', () => { vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X) Chrome/140.0.0.0', diff --git a/tests/Unit/GifKeySetting.test.tsx b/tests/Unit/GifKeySetting.test.tsx new file mode 100644 index 000000000..06b110a70 --- /dev/null +++ b/tests/Unit/GifKeySetting.test.tsx @@ -0,0 +1,426 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { afterEach, beforeEach, test, vi } from 'vite-plus/test'; + +const originalWindow = globalThis.window; + +let state: unknown[] = []; +let cursor = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: () => void) => effect(), + useMemo: (factory: () => unknown) => factory(), + useId: () => `preview-${cursor++}`, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), + useRef: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = { current: initial }; + } + + return state[index]; + }, + useState: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = typeof initial === 'function' ? initial() : initial; + } + + return [ + state[index], + (value: unknown) => { + state[index] = + typeof value === 'function' + ? (value as (current: unknown) => unknown)(state[index]) + : value; + }, + ]; + }, +})); + +vi.doMock('../../resources/js/hooks/use-user', () => ({ + useUser: () => ({ user: { id: 'user-1' } }), +})); + +const { GifKeySetting } = await import( + '../../resources/js/pages/user-profile/edit' +); +const { readGifProviderKey, writeGifProviderKey } = await import( + '../../resources/js/lib/gif-provider-key' +); + +type Element = ReactElement>; + +let store: Map; + +function elements(node: ReactNode): Element[] { + if (!React.isValidElement(node)) { + return []; + } + + const element = node as Element; + + return [ + element, + ...React.Children.toArray(element.props.children as ReactNode).flatMap( + elements, + ), + ]; +} + +function render(): Element[] { + cursor = 0; + + return elements(GifKeySetting()); +} + +function field(fields: Element[], testId: string): Element { + const found = fields.find( + (element) => element.props['data-test'] === testId, + ); + + assert.ok(found, `no element with data-test="${testId}"`); + + return found; +} + +function stubFetch(result: number | 'network'): void { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + if (result === 'network') { + throw new TypeError('fetch failed'); + } + + return new Response('{}', { status: result }); + }), + ); +} + +function typeKey(fields: Element[], value: string): Element[] { + (field(fields, 'gif-key-input').props.onChange as (event: { + target: { value: string }; + }) => void)({ target: { value } }); + + return render(); +} + +function selectProvider(fields: Element[], id: 'giphy' | 'klipy'): Element[] { + (field(fields, `provider-${id}`).props.onClick as () => void)(); + + return render(); +} + +beforeEach(() => { + state = []; + cursor = 0; + store = new Map(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + + globalThis.window = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as Window & typeof globalThis; +}); + +afterEach(() => { + globalThis.window = originalWindow; +}); + +test('shows the browser-only notice', () => { + const note = field(render(), 'gif-storage-note'); + + assert.equal( + React.Children.toArray(note.props.children as ReactNode) + .map((child) => (typeof child === 'string' ? child : '')) + .join('') + .replace(/\s+/g, ' ') + .trim(), + 'Stored in this browser only — never sent to the server. Each device needs its own key.', + ); +}); + +test('lists klipy first', () => { + const labels = render() + .filter((element) => + String(element.props['data-test']).startsWith('provider-'), + ) + .map((element) => element.props.children); + + assert.deepEqual(labels, ['KLIPY', 'GIPHY']); +}); + +test('saves a valid key to local storage after giphy accepts it', async () => { + stubFetch(200); + + let fields = selectProvider(render(), 'giphy'); + fields = typeKey(fields, ' key-123 '); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.deepEqual(store.get('gif-provider:user-1'), JSON.stringify({ provider: 'giphy', key: 'key-123' })); + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'key-123' }); + + fields = render(); + assert.equal( + React.Children.toArray( + field(fields, 'gif-connected').props.children as ReactNode, + ) + .map((child) => (typeof child === 'string' ? child : '')) + .join(''), + 'Connected to GIPHY.', + ); +}); + +test('defaults to klipy when no provider is stored', async () => { + stubFetch(200); + + const fields = typeKey(render(), 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'key-123' }); +}); + +test('saves a rate-limited key because the key itself is real', async () => { + stubFetch(429); + + const fields = typeKey(render(), 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'key-123' }); +}); + +test('rejects a refused key without saving', async () => { + stubFetch(401); + + let fields = selectProvider(render(), 'giphy'); + fields = typeKey(fields, 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(readGifProviderKey('user-1'), null); + assert.equal( + field(render(), 'gif-key-error').props.children, + 'That key was rejected by GIPHY.', + ); +}); + +test('names klipy when it refuses the key', async () => { + stubFetch(401); + + const fields = typeKey(render(), 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(readGifProviderKey('user-1'), null); + assert.equal( + field(render(), 'gif-key-error').props.children, + 'That key was rejected by KLIPY.', + ); +}); + +test('clears the error when another provider is selected', async () => { + stubFetch(401); + + const fields = typeKey(render(), 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.ok(field(render(), 'gif-key-error')); + + (field(render(), 'provider-giphy').props.onClick as () => void)(); + + assert.equal( + render().some( + (element) => element.props['data-test'] === 'gif-key-error', + ), + false, + ); +}); + +test('does not save when the provider cannot be reached', async () => { + stubFetch('network'); + + let fields = selectProvider(render(), 'giphy'); + fields = typeKey(fields, 'key-123'); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(readGifProviderKey('user-1'), null); + assert.equal( + field(render(), 'gif-key-error').props.children, + "Couldn't reach GIPHY to verify the key.", + ); +}); + +test('disables connect until a key is typed', () => { + assert.equal(field(render(), 'gif-connect-button').props.disabled, true); +}); + +test('does not save a whitespace-only key', async () => { + stubFetch(200); + + const fields = typeKey(render(), ' '); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(readGifProviderKey('user-1'), null); +}); + +test('rejects an overlong key before verifying it', async () => { + const fetchMock = vi.fn( + async () => new Response('{}', { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + const fields = typeKey(render(), 'a'.repeat(256)); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.equal(fetchMock.mock.calls.length, 0); + assert.equal(readGifProviderKey('user-1'), null); + assert.equal( + field(render(), 'gif-key-error').props.children, + 'That key is too long.', + ); +}); + +test('keeps the typed key and edit mode when the browser refuses to save', async () => { + stubFetch(200); + + writeGifProviderKey('user-1', { provider: 'giphy', key: 'old' }); + + let fields = render(); + (field(fields, 'change-provider-button').props.onClick as () => void)(); + fields = render(); + + globalThis.window.localStorage.setItem = () => undefined; + + (field(fields, 'gif-key-input').props.onChange as (event: { + target: { value: string }; + }) => void)({ target: { value: 'new-key' } }); + fields = render(); + + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + fields = render(); + + assert.equal(field(fields, 'gif-key-input').props.value, 'new-key'); + assert.equal( + field(fields, 'gif-key-error').props.children, + "Couldn't save the key in this browser.", + ); + assert.deepEqual(readGifProviderKey('user-1'), { + provider: 'giphy', + key: 'old', + }); +}); + +test('shows the connected provider from local storage', () => { + writeGifProviderKey('user-1', { provider: 'klipy', key: 'k' }); + + const fields = render(); + + assert.equal( + React.Children.toArray( + field(fields, 'gif-connected').props.children as ReactNode, + ) + .map((child) => (typeof child === 'string' ? child : '')) + .join(''), + 'Connected to KLIPY.', + ); +}); + +test('describes the stored provider instead of the default one', () => { + let fields = render(); + + assert.ok( + fields.some( + (element) => element.props.href === 'https://partner.klipy.com', + ), + ); + + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k' }); + + fields = render(); + + const description = fields.find( + (element) => element.props.id === 'gif-key-description', + ); + + assert.ok(description); + assert.ok( + React.Children.toArray(description.props.children as ReactNode) + .map((child) => (typeof child === 'string' ? child : '')) + .join('') + .includes('GIPHY'), + ); + assert.ok( + fields.some( + (element) => element.props.href === 'https://developers.giphy.com', + ), + ); +}); + +test('never echoes the stored key in the connected view', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'super-secret-key' }); + + const fields = render(); + assert.ok(field(fields, 'gif-connected')); + + const text = fields + .flatMap((element) => + React.Children.toArray(element.props.children as ReactNode), + ) + .map((child) => (typeof child === 'string' ? child : '')) + .join(''); + + assert.equal(text.includes('super-secret-key'), false); +}); + +test('removes the stored key', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k' }); + + const fields = render(); + (field(fields, 'gif-remove-button').props.onClick as () => void)(); + + assert.equal(readGifProviderKey('user-1'), null); + assert.ok(field(render(), 'gif-key-input')); +}); + +test('replaces the connected provider when another is chosen', async () => { + stubFetch(200); + + writeGifProviderKey('user-1', { provider: 'giphy', key: 'old' }); + + let fields = render(); + (field(fields, 'change-provider-button').props.onClick as () => void)(); + + fields = render(); + (field(fields, 'provider-klipy').props.onClick as () => void)(); + + fields = render(); + (field(fields, 'gif-key-input').props.onChange as (event: { + target: { value: string }; + }) => void)({ target: { value: 'klipy-key' } }); + + fields = render(); + await (field(fields, 'gif-connect-button').props.onClick as () => Promise)(); + + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'klipy', key: 'klipy-key' }); + + fields = render(); + (field(fields, 'change-provider-button').props.onClick as () => void)(); + fields = render(); + + assert.equal(field(fields, 'gif-key-input').props.value, ''); +}); diff --git a/tests/Unit/GifPickerDialog.test.tsx b/tests/Unit/GifPickerDialog.test.tsx new file mode 100644 index 000000000..bbd2227ce --- /dev/null +++ b/tests/Unit/GifPickerDialog.test.tsx @@ -0,0 +1,343 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { afterEach, beforeEach, test, vi } from 'vite-plus/test'; +import type { GifProvider } from '../../resources/js/lib/gif-providers'; + +const originalWindow = globalThis.window; + +let state: unknown[] = []; +let cursor = 0; + +vi.doMock('react', () => ({ + ...React, + useEffect: (effect: () => void) => effect(), + useMemo: (factory: () => unknown) => factory(), + useId: () => `preview-${cursor++}`, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), + useRef: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = { current: initial }; + } + + return state[index]; + }, + useState: (initial: unknown) => { + const index = cursor++; + + if (!(index in state)) { + state[index] = typeof initial === 'function' ? initial() : initial; + } + + return [ + state[index], + (value: unknown) => { + state[index] = + typeof value === 'function' + ? (value as (current: unknown) => unknown)(state[index]) + : value; + }, + ]; + }, +})); + +vi.doMock('@inertiajs/react', async () => ({ + ...(await vi.importActual('@inertiajs/react')), + usePage: () => ({ props: { auth: { user: { id: 'user-1' } } } }), +})); + +const result = { + id: 'one', + url: 'https://static.klipy.com/ii/one.gif', + pageUrl: 'https://klipy.com/gifs/one', + title: 'One', + username: null, + width: 100, + height: 100, + previewUrl: 'https://static.klipy.com/ii/one-preview.gif', +}; + +const search = vi.fn(async () => ({ + results: [result], + nextCursor: null, +})); +const trending = vi.fn(async () => ({ + results: [result], + nextCursor: null, +})); + +const provider: GifProvider = { + id: 'klipy', + label: 'FAUX', + searchPlaceholder: 'Find FAUX GIFs', + attribution: { label: 'Powered by FAUX', href: 'https://faux.example' }, + search, + trending, + validateKey: vi.fn(async () => 'valid' as const), +}; + +const { GifPickerDialog } = await import( + '../../resources/js/components/gif-picker-dialog' +); +const { ComposerMediaButton } = await import( + '../../resources/js/components/commit-image-picker' +); +const { gifProviderFor } = await import( + '../../resources/js/lib/gif-providers' +); + +let store: Map; + +type Element = ReactElement>; + +function elements(node: ReactNode): Element[] { + if (!React.isValidElement(node)) { + return []; + } + + const element = node as Element; + + return [ + element, + ...React.Children.toArray(element.props.children as ReactNode).flatMap( + elements, + ), + ]; +} + +function render(open = true, onPick = vi.fn()): Element[] { + cursor = 0; + + return elements( + GifPickerDialog({ + provider, + apiKey: 'k', + open, + onOpenChange: vi.fn(), + onPick, + }), + ); +} + +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function field(fields: Element[], testId: string): Element { + const found = fields.find( + (element) => element.props['data-test'] === testId, + ); + + assert.ok(found, `no element with data-test="${testId}"`); + + return found; +} + +beforeEach(() => { + state = []; + cursor = 0; + store = new Map(); + globalThis.window = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } as unknown as Window & typeof globalThis; + vi.clearAllMocks(); + search.mockImplementation(async () => ({ + results: [result], + nextCursor: null, + })); + trending.mockImplementation(async () => ({ + results: [result], + nextCursor: null, + })); +}); + +afterEach(() => { + globalThis.window = originalWindow; +}); + +test('uses the provider placeholder and attribution', () => { + const fields = render(false); + + assert.equal( + field(fields, 'gif-search-input').props.placeholder, + 'Find FAUX GIFs', + ); + assert.ok( + fields.some((element) => element.props.children === 'Powered by FAUX'), + ); + assert.ok( + fields.some( + (element) => element.props.href === 'https://faux.example', + ), + ); +}); + +test('searches through the injected provider', async () => { + render(); + await vi.waitFor(() => { + assert.equal(trending.mock.calls.length, 1); + }); + assert.equal(search.mock.calls.length, 0); +}); + +test('searches with the typed query', async () => { + const fields = render(); + + ( + field(fields, 'gif-search-input').props.onChange as (event: { + target: { value: string }; + }) => void + )({ target: { value: 'cats' } }); + + render(); + + await vi.waitFor(() => { + assert.equal(search.mock.calls.length, 1); + }); + assert.deepEqual(search.mock.calls[0].slice(0, 2), ['k', 'cats']); +}); + +test('picks the clicked result through the injected provider', async () => { + const onPick = vi.fn(); + render(true, onPick); + await settle(); + + const fields = render(true, onPick); + const button = elements(field(fields, 'gif-results')).find( + (element) => element.type === 'button', + ); + + assert.ok(button); + (button.props.onClick as () => void)(); + + assert.deepEqual(onPick.mock.calls[0]?.[0], { + ...result, + provider: 'klipy', + }); +}); + +test('loads more with the cursor from the previous page', async () => { + trending.mockImplementation( + async ( + _key: string, + options: { cursor: string | null }, + ) => + options.cursor === null + ? { results: [result], nextCursor: 'next-page' } + : { + results: [{ ...result, id: 'two' }], + nextCursor: null, + }, + ); + + render(); + await settle(); + + const fields = render(); + const loadMore = fields.find( + (element) => element.props.children === 'Load more', + ); + + assert.ok(loadMore); + (loadMore.props.onClick as () => void)(); + + const [key, options] = trending.mock.calls.at(-1) ?? []; + + assert.equal(key, 'k'); + assert.equal(options?.cursor, 'next-page'); + assert.ok(options?.signal instanceof AbortSignal); +}); + +test('offers load more when a page filters to no results', async () => { + trending.mockImplementation( + async (_key: string, options: { cursor: string | null }) => + options.cursor === null + ? { results: [], nextCursor: 'next-page' } + : { results: [result], nextCursor: null }, + ); + + render(); + await settle(); + + const fields = render(); + + assert.ok( + fields.some( + (element) => + element.props.children === + 'No GIFs matched. /dev/null remains empty.', + ), + ); + assert.ok( + fields.some((element) => element.props.children === 'Load more'), + ); +}); + +test('renders no GIF dialog without a provider key and keeps the image picker working', () => { + const fields = elements( + ComposerMediaButton({ + gifKey: null, + gifOpen: false, + onGifOpenChange: vi.fn(), + hasGif: false, + count: 0, + onPickImages: vi.fn(async () => {}), + onPickGif: vi.fn(), + }) as ReactElement, + ); + + assert.equal( + fields.some((element) => element.props.apiKey !== undefined), + false, + ); + assert.ok(field(fields, 'attach-images-button')); +}); + +test('opens the picker with the stored provider and key through one media trigger', () => { + const onGifOpenChange = vi.fn(); + const fields = elements( + ComposerMediaButton({ + gifKey: { provider: 'klipy', key: 'stored-key' }, + gifOpen: false, + onGifOpenChange, + hasGif: false, + count: 0, + onPickImages: vi.fn(async () => {}), + onPickGif: vi.fn(), + }) as ReactElement, + ); + const dialog = fields.find( + (element) => element.props.apiKey === 'stored-key', + ); + + assert.ok(dialog); + assert.equal(dialog.props.provider, gifProviderFor('klipy')); + assert.equal(dialog.props.open, false); + assert.ok(field(fields, 'attach-media-button')); + assert.ok(field(fields, 'attach-images-button')); + assert.ok(field(fields, 'attach-gif-button')); + + const preventDefault = vi.fn(); + ( + field(fields, 'attach-gif-button').props.onSelect as (event: { + preventDefault: () => void; + }) => void + )({ preventDefault }); + + assert.deepEqual(onGifOpenChange.mock.calls, [[true]]); + assert.equal(preventDefault.mock.calls.length, 0); +}); diff --git a/tests/Unit/GifProviderKey.test.ts b/tests/Unit/GifProviderKey.test.ts new file mode 100644 index 000000000..07abc2cd8 --- /dev/null +++ b/tests/Unit/GifProviderKey.test.ts @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test } from 'node:test'; +import type { GifAttachment } from '../../resources/js/lib/gif-providers.ts'; +import { + clearGifProviderKey, + readGifProviderKey, + readStoredGif, + subscribeGifProviderKey, + writeGifProviderKey, + writeStoredGif, +} from '../../resources/js/lib/gif-provider-key.ts'; + +const gif: GifAttachment = { + provider: 'giphy', + id: 'xT9IgzoKnwFNmISR8I', + url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + pageUrl: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + width: 480, + height: 270, + previewUrl: + 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc123', +}; + +let store: Map; +let storageHandlers: Array<(event: StorageEvent) => void>; +const originalWindow = globalThis.window; + +function installWindow(): void { + store = new Map(); + storageHandlers = []; + + globalThis.window = { + localStorage: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + key: (index: number) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + }, + addEventListener: (_type: string, handler: (event: StorageEvent) => void) => { + storageHandlers.push(handler); + }, + removeEventListener: (_type: string, handler: (event: StorageEvent) => void) => { + storageHandlers = storageHandlers.filter((candidate) => candidate !== handler); + }, + } as unknown as Window & typeof globalThis; +} + +beforeEach(installWindow); + +afterEach(() => { + globalThis.window = originalWindow; +}); + +test('round-trips a key and keeps a stable reference while unchanged', () => { + writeGifProviderKey('user-1', { provider: 'klipy', key: 'k-123' }); + + const first = readGifProviderKey('user-1'); + const second = readGifProviderKey('user-1'); + + assert.deepEqual(first, { provider: 'klipy', key: 'k-123' }); + assert.equal(first, second); +}); + +test('namespaces keys per user', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'one' }); + writeGifProviderKey('user-2', { provider: 'klipy', key: 'two' }); + + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'one' }); + assert.deepEqual(readGifProviderKey('user-2'), { provider: 'klipy', key: 'two' }); + assert.equal(readGifProviderKey('user-3'), null); +}); + +test('treats malformed values as absent', () => { + store.set('gif-provider:user-1', '{not json'); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'unknown', key: 'x' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'toString', key: 'x' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: '__proto__', key: 'x' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: ' ' })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 'a'.repeat(256) })); + assert.equal(readGifProviderKey('user-1'), null); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 42 })); + assert.equal(readGifProviderKey('user-1'), null); +}); + +test('writes the trimmed key and notifies same-tab subscribers', () => { + const calls: string[] = []; + const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); + + writeGifProviderKey('user-1', { provider: 'giphy', key: ' k-123 ' }); + + assert.equal(store.get('gif-provider:user-1'), JSON.stringify({ provider: 'giphy', key: 'k-123' })); + assert.deepEqual(calls, ['hit']); + + unsubscribe(); + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-456' }); + assert.deepEqual(calls, ['hit']); +}); + +test('clears the entry and notifies', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-123' }); + + const calls: string[] = []; + const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); + + clearGifProviderKey('user-1'); + + assert.equal(store.has('gif-provider:user-1'), false); + assert.deepEqual(calls, ['hit']); + assert.equal(readGifProviderKey('user-1'), null); + + unsubscribe(); +}); + +test('refuses invalid keys and reports nothing was written', () => { + assert.equal( + writeGifProviderKey('user-1', { provider: 'giphy', key: ' ' }), + false, + ); + assert.equal( + writeGifProviderKey('user-1', { + provider: 'giphy', + key: 'a'.repeat(256), + }), + false, + ); + assert.equal(store.has('gif-provider:user-1'), false); + assert.equal(readGifProviderKey('user-1'), null); +}); + +test('reports failure when storage swallows the write', () => { + globalThis.window.localStorage.setItem = () => undefined; + + assert.equal( + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-123' }), + false, + ); + assert.equal(readGifProviderKey('user-1'), null); +}); + +test('reacts to storage events for its own key only', () => { + const calls: string[] = []; + const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); + + assert.equal(storageHandlers.length, 1); + + storageHandlers[0]!({ key: 'gif-provider:user-2' } as StorageEvent); + assert.deepEqual(calls, []); + + store.set('gif-provider:user-1', JSON.stringify({ provider: 'giphy', key: 'other-tab' })); + storageHandlers[0]!({ key: 'gif-provider:user-1' } as StorageEvent); + + assert.deepEqual(calls, ['hit']); + assert.deepEqual(readGifProviderKey('user-1'), { provider: 'giphy', key: 'other-tab' }); + + unsubscribe(); + assert.equal(storageHandlers.length, 0); +}); + +test('reacts to a full storage clear from another tab', () => { + writeGifProviderKey('user-1', { provider: 'giphy', key: 'k-123' }); + + const calls: string[] = []; + const unsubscribe = subscribeGifProviderKey('user-1', () => calls.push('hit')); + + store.clear(); + storageHandlers[0]!({ key: null } as unknown as StorageEvent); + + assert.deepEqual(calls, ['hit']); + assert.equal(readGifProviderKey('user-1'), null); + + unsubscribe(); +}); + +test('round-trips a stashed gif', () => { + writeStoredGif('stash:u1:commit', gif); + assert.deepEqual(readStoredGif('stash:u1:commit'), gif); +}); + +await test('clears a stashed gif', () => { + writeStoredGif('stash:u1:commit', gif); + writeStoredGif('stash:u1:commit', null); + assert.equal(readStoredGif('stash:u1:commit'), null); +}); + +await test('ignores unreadable stashed values', () => { + store.set('stash:u1:reply-1:gif', 'not json'); + assert.equal(readStoredGif('stash:u1:reply-1'), null); + store.set('stash:u1:reply-2:gif', JSON.stringify({ nope: true })); + assert.equal(readStoredGif('stash:u1:reply-2'), null); +}); + +await test('rejects a stashed gif with an unknown provider', () => { + store.set( + 'stash:u1:reply-3:gif', + JSON.stringify({ ...gif, provider: 'nope' }), + ); + assert.equal(readStoredGif('stash:u1:reply-3'), null); +}); diff --git a/tests/Unit/GifProviders.test.ts b/tests/Unit/GifProviders.test.ts new file mode 100644 index 000000000..fe10bb8aa --- /dev/null +++ b/tests/Unit/GifProviders.test.ts @@ -0,0 +1,617 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + attachmentFromResult, + gifFormFields, + giphy, + GifRateLimited, + isBlockedGifText, + isSafeGiphyRating, + klipy, +} from '../../resources/js/lib/gif-providers.ts'; + +const originalFetch = globalThis.fetch; + +function stubFetch(payload: unknown, status = 200): string[] { + const calls: string[] = []; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(String(input)); + + return new Response(JSON.stringify(payload), { status }); + }) as typeof fetch; + + return calls; +} + +function restoreFetch(): void { + globalThis.fetch = originalFetch; +} + +const giphyPayload = { + data: [{ + id: 'xT9IgzoKnwFNmISR8I', + title: 'Deal With It GIF', + username: 'giphy', + rating: 'g', + url: 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + images: { + fixed_height_small: { url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc', width: '178', height: '100' }, + downsized_medium: { url: 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc', width: '480', height: '270' }, + }, + }], +}; + +const klipyPayload = { + results: [{ + id: 'hello-hi-662', + title: 'Hello', + content_description: 'hello greeting', + itemurl: 'https://klipy.com/gifs/hello-hi-662', + media_formats: { + gif: { url: 'https://static.klipy.com/ii/8GCrVAB7.gif', dims: [498, 498] }, + tinygif: { url: 'https://static.klipy.com/ii/y6iepZM7.gif', dims: [220, 220] }, + }, + }], + next: 'cursor-2', +}; + +test('giphy builds search and trending urls with the g rating', async () => { + const searchCalls = stubFetch(giphyPayload); + try { + const page = await giphy.search('key-123', 'deal with it', { cursor: null }); + const url = new URL(searchCalls[0]!); + assert.equal(url.origin + url.pathname, 'https://api.giphy.com/v1/gifs/search'); + assert.equal(url.searchParams.get('api_key'), 'key-123'); + assert.equal(url.searchParams.get('q'), 'deal with it'); + assert.equal(url.searchParams.get('rating'), 'g'); + assert.equal(url.searchParams.get('offset'), '0'); + assert.equal(page.results[0]?.url, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc'); + assert.equal(page.results[0]?.previewUrl, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/100.gif?cid=abc'); + assert.equal(page.nextCursor, null); + } finally { + restoreFetch(); + } + + const trendingCalls = stubFetch(giphyPayload); + try { + await giphy.trending('key-123', { cursor: '24' }); + const trendingUrl = new URL(trendingCalls[0]!); + assert.ok(trendingCalls[0]!.includes('/v1/gifs/trending?')); + assert.equal(trendingUrl.searchParams.get('offset'), '24'); + assert.equal(trendingUrl.searchParams.get('rating'), 'g'); + } finally { + restoreFetch(); + } +}); + +test('giphy derives the next cursor from a full page and passes it back as offset', async () => { + const fullPage = { + data: Array.from({ length: 24 }, (_, index) => ({ + id: `gif-${index}`, + title: `GIF ${index}`, + username: 'giphy', + rating: 'g', + url: `https://giphy.com/gifs/gif-${index}`, + images: { + downsized_medium: { + url: `https://media2.giphy.com/media/gif-${index}/giphy.gif`, + width: '480', + height: '270', + }, + }, + })), + }; + + stubFetch(fullPage); + try { + const page = await giphy.search('key-123', 'deal', { cursor: null }); + assert.equal(page.nextCursor, '24'); + } finally { + restoreFetch(); + } + + const nextCalls = stubFetch(fullPage); + try { + const page = await giphy.search('key-123', 'deal', { cursor: '24' }); + const url = new URL(nextCalls[0]!); + assert.equal(url.searchParams.get('offset'), '24'); + assert.equal(page.nextCursor, '48'); + } finally { + restoreFetch(); + } +}); + +test('klipy builds tenor-compatible urls and maps the next cursor', async () => { + const calls = stubFetch(klipyPayload); + try { + const page = await klipy.search('k-key', 'hello', { cursor: null }); + const url = new URL(calls[0]!); + assert.equal(url.origin + url.pathname, 'https://api.klipy.com/v2/search'); + assert.equal(url.searchParams.get('key'), 'k-key'); + assert.equal(url.searchParams.get('q'), 'hello'); + assert.equal(url.searchParams.get('contentfilter'), 'high'); + assert.equal(page.results[0]?.pageUrl, 'https://klipy.com/gifs/hello-hi-662'); + assert.equal(page.results[0]?.username, null); + assert.equal(page.results[0]?.previewUrl, 'https://static.klipy.com/ii/y6iepZM7.gif'); + assert.equal(page.nextCursor, 'cursor-2'); + } finally { + restoreFetch(); + } + + const nextCalls = stubFetch(klipyPayload); + try { + await klipy.search('k-key', 'hello', { cursor: 'cursor-2' }); + const url = new URL(nextCalls[0]!); + assert.equal(url.searchParams.get('pos'), 'cursor-2'); + } finally { + restoreFetch(); + } + + const trendingCalls = stubFetch({ results: [], next: '' }); + try { + const page = await klipy.trending('k-key', { cursor: 'cursor-2' }); + const url = new URL(trendingCalls[0]!); + assert.ok(trendingCalls[0]!.includes('/v2/featured?')); + assert.equal(url.searchParams.get('contentfilter'), 'high'); + assert.equal(url.searchParams.get('pos'), 'cursor-2'); + assert.equal(page.nextCursor, null); + } finally { + restoreFetch(); + } +}); + +test('adapters drop items without a usable rendition', async () => { + stubFetch({ + data: [{ + id: 'no-renditions', + title: 'Nothing', + username: '', + rating: 'g', + url: 'https://giphy.com/gifs/nothing', + images: {}, + }], + }); + try { + const page = await giphy.search('key-123', 'nothing', { cursor: null }); + assert.deepEqual(page.results, []); + } finally { + restoreFetch(); + } + + stubFetch({ + results: [{ + id: 'no-gif', + title: 'Nothing', + itemurl: 'https://klipy.com/gifs/no-gif', + media_formats: { + tinygif: { url: 'https://static.klipy.com/ii/no-gif.gif', dims: [220, 220] }, + }, + }], + next: '', + }); + try { + const page = await klipy.search('k-key', 'nothing', { cursor: null }); + assert.deepEqual(page.results, []); + } finally { + restoreFetch(); + } +}); + +const rendition = (slug: string) => ({ + url: `https://media2.giphy.com/media/${slug}.gif`, + width: '480', + height: '270', +}); + +test('giphy derives the next cursor from the raw page size after filtering', async () => { + const safe = (index: number) => ({ + id: `gif-${index}`, + title: `GIF ${index}`, + username: 'giphy', + rating: 'g', + url: `https://giphy.com/gifs/gif-${index}`, + images: { original: rendition(`gif-${index}`) }, + }); + + stubFetch({ + data: Array.from({ length: 24 }, (_, index) => + index === 0 + ? { ...safe(index), rating: 'r' } + : safe(index), + ), + }); + + try { + const page = await giphy.search('key-123', 'gif', { cursor: null }); + + assert.equal(page.results.length, 23); + assert.equal(page.nextCursor, '24'); + } finally { + restoreFetch(); + } +}); + +test('giphy falls back across stored renditions and previews', async () => { + const cases: { + name: string; + images: Record>; + stored: string; + preview: string; + }[] = [ + { + name: 'stored downsized', + images: { downsized: rendition('downsized') }, + stored: 'https://media2.giphy.com/media/downsized.gif', + preview: 'https://media2.giphy.com/media/downsized.gif', + }, + { + name: 'stored original', + images: { original: rendition('original') }, + stored: 'https://media2.giphy.com/media/original.gif', + preview: 'https://media2.giphy.com/media/original.gif', + }, + { + name: 'fixed height small preview', + images: { + fixed_height_small: rendition('small'), + downsized: rendition('downsized'), + downsized_medium: rendition('downsized-medium'), + original: rendition('original'), + }, + stored: 'https://media2.giphy.com/media/downsized-medium.gif', + preview: 'https://media2.giphy.com/media/small.gif', + }, + { + name: 'downsized preview', + images: { + downsized: rendition('downsized'), + downsized_medium: rendition('downsized-medium'), + original: rendition('original'), + }, + stored: 'https://media2.giphy.com/media/downsized-medium.gif', + preview: 'https://media2.giphy.com/media/downsized.gif', + }, + { + name: 'downsized medium preview', + images: { + downsized_medium: rendition('downsized-medium'), + original: rendition('original'), + }, + stored: 'https://media2.giphy.com/media/downsized-medium.gif', + preview: 'https://media2.giphy.com/media/downsized-medium.gif', + }, + { + name: 'original preview', + images: { original: rendition('original') }, + stored: 'https://media2.giphy.com/media/original.gif', + preview: 'https://media2.giphy.com/media/original.gif', + }, + ]; + + for (const { name, images, stored, preview } of cases) { + stubFetch({ + data: [{ + id: 'gif', + title: 'GIF', + username: 'giphy', + rating: 'g', + url: 'https://giphy.com/gifs/gif', + images, + }], + }); + + try { + const page = await giphy.search('key-123', 'gif', { + cursor: null, + }); + + assert.equal(page.results[0]?.url, stored, name); + assert.equal(page.results[0]?.previewUrl, preview, name); + } finally { + restoreFetch(); + } + } +}); + +test('giphy normalizes empty titles and usernames to null', async () => { + stubFetch({ + data: [{ + id: 'gif', + title: '', + username: '', + rating: 'g', + url: 'https://giphy.com/gifs/gif', + images: { original: rendition('original') }, + }], + }); + + try { + const page = await giphy.search('key-123', 'gif', { cursor: null }); + + assert.equal(page.results[0]?.title, null); + assert.equal(page.results[0]?.username, null); + } finally { + restoreFetch(); + } +}); + +test('giphy drops results without a safe rating', async () => { + const item = { + id: 'gif', + title: 'GIF', + username: 'giphy', + url: 'https://giphy.com/gifs/gif', + images: { original: rendition('original') }, + }; + + for (const [rating, expected] of [ + ['g', 1], + ['pg', 0], + ['pg-13', 0], + ['r', 0], + ['y', 0], + [undefined, 0], + ] as const) { + stubFetch({ data: [{ ...item, rating }] }); + + try { + const page = await giphy.search('key-123', 'gif', { + cursor: null, + }); + + assert.equal(page.results.length, expected, String(rating)); + } finally { + restoreFetch(); + } + } +}); + +test('giphy drops blocked titles and usernames', async () => { + const item = (id: string, title: string, username: string) => ({ + id, + title, + username, + rating: 'g', + url: `https://giphy.com/gifs/${id}`, + images: { original: rendition(id) }, + }); + + stubFetch({ + data: [ + item('blocked-title', 'PORN star', 'giphy'), + item('blocked-username', 'Hello', 'xxx'), + item('safe', 'Hello', 'giphy'), + ], + }); + + try { + const page = await giphy.search('key-123', 'hello', { + cursor: null, + }); + + assert.deepEqual(page.results.map((result) => result.id), ['safe']); + } finally { + restoreFetch(); + } +}); + +test('klipy drops blocked titles and descriptions', async () => { + const item = ( + id: string, + title: string, + description: string, + ) => ({ + id, + title, + content_description: description, + itemurl: `https://klipy.com/gifs/${id}`, + media_formats: { + gif: { + url: `https://static.klipy.com/ii/${id}.gif`, + dims: [498, 498] as [number, number], + }, + }, + }); + + stubFetch({ + results: [ + item('blocked-title', 'PORN', ''), + item('blocked-description', 'Hello', 'gory scene'), + item('safe', 'Hello', 'hello greeting'), + ], + next: '', + }); + + try { + const page = await klipy.search('k-key', 'hello', { + cursor: null, + }); + + assert.deepEqual(page.results.map((result) => result.id), ['safe']); + } finally { + restoreFetch(); + } +}); + +test('klipy falls back to the content description for its title', async () => { + const cases: { + name: string; + title: string | undefined; + description: string | undefined; + expected: string | null; + }[] = [ + { + name: 'empty title', + title: '', + description: 'hello greeting', + expected: 'hello greeting', + }, + { + name: 'absent title', + title: undefined, + description: 'hello greeting', + expected: 'hello greeting', + }, + { + name: 'empty fallback', + title: '', + description: '', + expected: null, + }, + { + name: 'absent both', + title: undefined, + description: undefined, + expected: null, + }, + ]; + + for (const { name, title, description, expected } of cases) { + stubFetch({ + results: [{ + id: 'hello-hi-662', + title, + content_description: description, + itemurl: 'https://klipy.com/gifs/hello-hi-662', + media_formats: { + gif: { + url: 'https://static.klipy.com/ii/8GCrVAB7.gif', + dims: [498, 498], + }, + }, + }], + next: '', + }); + + try { + const page = await klipy.search('k-key', 'hello', { + cursor: null, + }); + + assert.equal(page.results[0]?.title, expected, name); + } finally { + restoreFetch(); + } + } +}); + +test('klipy reuses the gif url when the tiny preview is missing', async () => { + stubFetch({ + results: [{ + id: 'hello-hi-662', + title: 'Hello', + itemurl: 'https://klipy.com/gifs/hello-hi-662', + media_formats: { + gif: { + url: 'https://static.klipy.com/ii/8GCrVAB7.gif', + dims: [498, 498], + }, + }, + }], + next: '', + }); + + try { + const page = await klipy.search('k-key', 'hello', { cursor: null }); + + assert.equal( + page.results[0]?.previewUrl, + 'https://static.klipy.com/ii/8GCrVAB7.gif', + ); + } finally { + restoreFetch(); + } +}); + +test('adapters reject non-429 http failures', async () => { + stubFetch({}, 500); + try { + await assert.rejects(() => giphy.trending('k', { cursor: null }), /request failed/); + await assert.rejects(() => klipy.trending('k', { cursor: null }), /request failed/); + } finally { + restoreFetch(); + } +}); + +test('adapters map throttling and key verdicts to the shared shapes', async () => { + stubFetch({}, 429); + try { + await assert.rejects(() => giphy.trending('k', { cursor: null }), GifRateLimited); + assert.equal(await giphy.validateKey('k'), 'rate-limited'); + assert.equal(await klipy.validateKey('k'), 'rate-limited'); + } finally { + restoreFetch(); + } + + for (const status of [401, 403]) { + stubFetch({}, status); + try { + assert.equal(await giphy.validateKey('k'), 'invalid'); + assert.equal(await klipy.validateKey('k'), 'invalid'); + } finally { + restoreFetch(); + } + } + + stubFetch({}, 200); + try { + assert.equal(await giphy.validateKey('k'), 'valid'); + assert.equal(await klipy.validateKey('k'), 'valid'); + } finally { + restoreFetch(); + } + + globalThis.fetch = (async () => { + throw new TypeError('network'); + }) as typeof fetch; + try { + assert.equal(await giphy.validateKey('k'), 'unreachable'); + assert.equal(await klipy.validateKey('k'), 'unreachable'); + } finally { + restoreFetch(); + } +}); + +test('attachments carry the provider and shape the hidden form fields', () => { + const gif = attachmentFromResult('klipy', { + id: 'hello-hi-662', + url: 'https://static.klipy.com/ii/8GCrVAB7.gif', + pageUrl: 'https://klipy.com/gifs/hello-hi-662', + title: 'Hello', + username: null, + width: 498, + height: 498, + previewUrl: 'https://static.klipy.com/ii/y6iepZM7.gif', + }); + + assert.deepEqual(gifFormFields(gif), { + gif_provider: 'klipy', + gif_id: 'hello-hi-662', + gif_url: 'https://static.klipy.com/ii/8GCrVAB7.gif', + gif_page_url: 'https://klipy.com/gifs/hello-hi-662', + gif_title: 'Hello', + gif_username: '', + gif_width: 498, + gif_height: 498, + }); +}); + +test('only g-rated giphy results are safe', () => { + assert.equal(isSafeGiphyRating('g'), true); + assert.equal(isSafeGiphyRating('G'), true); + assert.equal(isSafeGiphyRating('pg'), false); + assert.equal(isSafeGiphyRating('pg-13'), false); + assert.equal(isSafeGiphyRating('r'), false); + assert.equal(isSafeGiphyRating('y'), false); + assert.equal(isSafeGiphyRating(undefined), false); +}); + +test('blocked words match whole words in any case', () => { + assert.equal(isBlockedGifText('PORN star'), true); + assert.equal(isBlockedGifText('Essex'), false); + assert.equal(isBlockedGifText(null, 'Hello', 'NSFW'), true); + assert.equal(isBlockedGifText('gory scene'), true); + assert.equal(isBlockedGifText('cocaine'), true); + assert.equal(isBlockedGifText('hello greeting'), false); +}); diff --git a/tests/Unit/IssueCommands.test.ts b/tests/Unit/IssueCommands.test.ts index 82f553b67..79fcf6916 100644 --- a/tests/Unit/IssueCommands.test.ts +++ b/tests/Unit/IssueCommands.test.ts @@ -46,3 +46,29 @@ test('hints the command a plain draft mentions', () => { assert.equal(name('/bug already a command'), null); assert.equal(name('Shipped the parser'), null); }); + +test('suggests /gif only when a GIF provider key is stored', () => { + const names = (body: string, canAttachGif = false) => + suggestedCommands(body, { canAttachGif }).map( + (command) => command.name, + ); + + assert.deepEqual(names('/'), ['/feature', '/bug']); + assert.deepEqual(names('/', true), ['/feature', '/bug', '/gif']); + assert.deepEqual(names('/g', true), ['/gif']); + assert.deepEqual(names('/g', false), []); +}); + +test('a lone /gif command attaches a GIF without discarding typed text', () => { + assert.deepEqual(typedCommand('/gif ', { canAttachGif: true }), { + kind: 'gif', + body: '', + }); + assert.deepEqual(typedCommand('/gif\n', { canAttachGif: true }), { + kind: 'gif', + body: '', + }); + assert.equal(typedCommand('/gif', { canAttachGif: true }), null); + assert.equal(typedCommand('/gif cats ', { canAttachGif: true }), null); + assert.equal(typedCommand('/gif ', { canAttachGif: false }), null); +}); diff --git a/tests/Unit/Jobs/ModerateCommitTest.php b/tests/Unit/Jobs/ModerateCommitTest.php index b8049202f..36c8a2fb9 100644 --- a/tests/Unit/Jobs/ModerateCommitTest.php +++ b/tests/Unit/Jobs/ModerateCommitTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\CommitStatus; +use App\Enums\GifProvider; use App\Enums\NotificationType; use App\Exceptions\CommitProcessingFailed; use App\Jobs\ModerateCommit; @@ -24,6 +25,11 @@ config(['ai.providers.openai.key' => 'sk-test']); }); +function tinyGif(): string +{ + return base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); +} + /** * @param list $flagged * @param array $scores @@ -413,6 +419,36 @@ function reviewResponses(array $responses = []): Closure })->and($commit->refresh())->ai_decision->toBeNull()->status->toBe(CommitStatus::Pending); }); +it('moderates an attached giphy gif by its first frame', function (): void { + Http::fake([ + 'media2.giphy.com/*' => Http::response(tinyGif()), + 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), + 'api.openai.com/v1/responses' => reviewResponses(), + ]); + + $commit = Commit::factory()->pending()->withGif()->create(); + + dispatch_sync(new ModerateCommit($commit)); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'moderations') + && str_contains($request->body(), 'data:image')); +}); + +it('moderates an attached klipy gif by its first frame', function (): void { + Http::fake([ + 'static.klipy.com/*' => Http::response(tinyGif()), + 'api.openai.com/v1/moderations' => Http::response(moderationResponse()), + 'api.openai.com/v1/responses' => reviewResponses(), + ]); + + $commit = Commit::factory()->pending()->withGif(GifProvider::Klipy)->create(); + + dispatch_sync(new ModerateCommit($commit)); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'moderations') + && str_contains($request->body(), 'data:image')); +}); + it('keeps unavailable moderation pending and recovers on a later queued attempt', function (bool $recovers): void { config(['queue.default' => 'sync']); Exceptions::fake(); diff --git a/tests/Unit/Models/CommitTest.php b/tests/Unit/Models/CommitTest.php index 576130e08..99c02efde 100644 --- a/tests/Unit/Models/CommitTest.php +++ b/tests/Unit/Models/CommitTest.php @@ -4,7 +4,9 @@ use App\Actions\DeleteUser; use App\Enums\CommitStatus; +use App\Enums\GifProvider; use App\Models\Commit; +use App\Models\CommitGif; use App\Models\CommitImage; use App\Models\LinkPreview; use App\Models\User; @@ -30,6 +32,7 @@ 'hot_score', 'pasteis_count', 'organization_mentions', + 'gif', 'body_segments', 'preview_segments', 'is_checking', @@ -452,3 +455,59 @@ expect($tombstone->toArray())->not->toHaveKey('images'); }); + +it('serializes an attached gif as one object without its raw attributes', function (): void { + $commit = Commit::factory()->withGif()->create(); + + $payload = $commit->toArray(); + + expect($payload['gif'])->toBe([ + 'provider' => 'giphy', + 'id' => 'xT9IgzoKnwFNmISR8I', + 'url' => 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123', + 'page_url' => 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I', + 'title' => 'Deal With It GIF', + 'username' => 'giphy', + 'width' => 480, + 'height' => 270, + ])->and($payload)->toHaveKey('gif') + ->and(array_keys($payload))->each->not->toStartWith('gif_'); +}); + +it('keeps its gif in a dedicated row', function (): void { + $commit = Commit::factory()->withGif()->create(); + + expect($commit->gif)->not->toBeNull() + ->and($commit->gif->commit->is($commit))->toBeTrue(); +}); + +it('serializes the klipy provider too', function (): void { + $commit = Commit::factory()->withGif(GifProvider::Klipy)->create(); + + expect($commit->toArray()['gif'])->toMatchArray([ + 'provider' => 'klipy', + 'id' => 'hello-hi-662', + 'url' => 'https://static.klipy.com/ii/935d7ab9/kLipy.gif', + 'page_url' => 'https://klipy.com/gifs/hello-hi-662', + 'username' => null, + ]); +}); + +it('serializes the gif of a plain commit as null', function (): void { + expect(Commit::factory()->create()->toArray()['gif'])->toBeNull(); +}); + +it('leaves the gif out of a tombstone', function (): void { + $commit = Commit::factory()->withGif()->create(); + $commit->delete(); + + expect($commit->fresh()?->toArray())->not->toHaveKey('gif'); +}); + +it('creates its gif through the factory', function (): void { + $gif = CommitGif::factory()->create(); + + expect($gif->provider)->toBe(GifProvider::Giphy) + ->and($gif->commit)->toBeInstanceOf(Commit::class) + ->and($gif->toPayload()['id'])->toBe('xT9IgzoKnwFNmISR8I'); +}); diff --git a/tests/Unit/Queries/CommitBuilderTest.php b/tests/Unit/Queries/CommitBuilderTest.php index 37b01d8d5..558a8908e 100644 --- a/tests/Unit/Queries/CommitBuilderTest.php +++ b/tests/Unit/Queries/CommitBuilderTest.php @@ -66,7 +66,7 @@ $commits = Commit::query()->withStarsFor($viewer)->with('user')->get()->keyBy('id'); - expect(DB::getQueryLog())->toHaveCount(3) + expect(DB::getQueryLog())->toHaveCount(4) ->and($commits[$starred->id]->stars_count)->toBe(2) ->and($commits[$starred->id]->starred)->toBeTrue() ->and($commits[$plain->id]->stars_count)->toBe(0) @@ -207,3 +207,18 @@ expect($commit->continuation_id)->toBeNull() ->and($commit->replies_count)->toBe(0); }); + +it('eager loads gifs on a keyed commit collection', function (): void { + $commits = Commit::factory()->withGif()->count(3)->create(); + + $keyed = Commit::query() + ->whereIn('id', $commits->pluck('id')) + ->without('gif') + ->get() + ->keyBy('id'); + + $loaded = $keyed->load('user'); + + expect($loaded)->toHaveCount(3) + ->and($loaded->every(fn (Commit $commit): bool => $commit->relationLoaded('gif')))->toBeTrue(); +}); diff --git a/tests/Unit/Queries/NotificationGroupsQueryTest.php b/tests/Unit/Queries/NotificationGroupsQueryTest.php index ec1ce6fc2..2110fae3c 100644 --- a/tests/Unit/Queries/NotificationGroupsQueryTest.php +++ b/tests/Unit/Queries/NotificationGroupsQueryTest.php @@ -190,7 +190,7 @@ function followAt(User $follower, User $user, int $minutesAgo): void resolve(NotificationGroupsQuery::class)->paginate($user); - expect(DB::getQueryLog())->toHaveCount(6); + expect(DB::getQueryLog())->toHaveCount(7); })->with([1, 5]); it('skips loading stacked actors when nothing is grouped', function (): void { diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index 125df1f92..74cc6b125 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -14,14 +14,17 @@ const draft = { body: 'A restored quote', images: [image], descriptions: ['A restored screenshot'], + gif: null, isRestored: true, updateBody: vi.fn(), updateImages, + updateGif: vi.fn(), drop, }; vi.doMock('react', () => ({ ...React, + useEffect: () => {}, useLayoutEffect: (effect: () => void | (() => void)) => effect(), useMemo: (factory: () => unknown) => factory(), useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => @@ -34,7 +37,7 @@ vi.doMock('react', () => ({ })); vi.doMock('@inertiajs/react', async () => ({ ...(await vi.importActual('@inertiajs/react')), - usePage: () => ({ props: { auth: { user } } }), + usePage: () => ({ props: { auth: { user }, gif: null } }), })); vi.doMock('../../resources/js/hooks/use-user', () => ({ useUser: () => ({ user }), @@ -57,8 +60,12 @@ vi.doMock('../../resources/js/components/composer-preview', async () => ({ const { Form } = await import('@inertiajs/react'); const { CommitQuoteButton } = await import('../../resources/js/components/commit-quote-button'); -const { ImagePickerButton, ImagePickerPreviews } = - await import('../../resources/js/components/commit-image-picker'); +const { ComposerMediaButton } = await import( + '../../resources/js/components/commit-image-picker' +); +const { ImagePickerPreviews } = await import( + '../../resources/js/components/commit-image-picker' +); const { StashLabel } = await import('../../resources/js/components/stash-label'); const { LinkCardPicker } = @@ -133,14 +140,16 @@ test('quotes submit restored images and clear the complete draft only on success assert.deepEqual(previews.props.images, [image]); const picker = fields.find( - (element) => element.type === ImagePickerButton, + (element) => element.type === ComposerMediaButton, )!; assert.equal(picker.props.count, 1); const added = new File(['new'], 'new.png', { type: 'image/png', lastModified: 1, }); - await (picker.props.onPick as (files: File[]) => Promise)([added]); + await (picker.props.onPickImages as (files: File[]) => Promise)([ + added, + ]); const [pickedImages, descriptions] = updateImages.mock.lastCall as [ File[], string[], diff --git a/tests/Unit/Rules/GifUrlTest.php b/tests/Unit/Rules/GifUrlTest.php new file mode 100644 index 000000000..8ec25fda3 --- /dev/null +++ b/tests/Unit/Rules/GifUrlTest.php @@ -0,0 +1,102 @@ + $url], ['url' => [new GifUrl($provider)]]); + + expect($validator->passes())->toBeTrue(); +})->with([ + 'giphy' => [GifProvider::Giphy, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy-downsized-medium.gif?cid=abc123'], + 'klipy' => [GifProvider::Klipy, 'https://static2.klipy.com/ii/935d7ab9/14/af/8GCrVAB7.gif'], +]); + +it('rejects urls from the other provider', function (GifProvider $provider, string $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifUrl($provider)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'giphy url for klipy' => [GifProvider::Klipy, 'https://media2.giphy.com/media/xT9IgzoKnwFNmISR8I/giphy.gif'], + 'klipy url for giphy' => [GifProvider::Giphy, 'https://static.klipy.com/ii/935d7ab9/8GCrVAB7.gif'], +]); + +it('rejects unsafe or malformed media urls', function (mixed $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifUrl(GifProvider::Klipy)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'not https' => ['http://static.klipy.com/ii/x.gif'], + 'not a gif' => ['https://static.klipy.com/ii/x.webp'], + 'with port' => ['https://static.klipy.com:8443/ii/x.gif'], + 'with userinfo' => ['https://user:pass@static.klipy.com/ii/x.gif'], + 'dot segment' => ['https://static.klipy.com/ii/../x.gif'], + 'array value' => [['nope']], +]); + +it('accepts matching provider page urls', function (GifProvider $provider, string $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifUrl($provider, page: true)]]); + + expect($validator->passes())->toBeTrue(); +})->with([ + 'giphy' => [GifProvider::Giphy, 'https://giphy.com/gifs/deal-with-it-xT9IgzoKnwFNmISR8I'], + 'klipy' => [GifProvider::Klipy, 'https://klipy.com/gifs/hello-hi-662'], +]); + +it('rejects page urls from the other provider', function (GifProvider $provider, string $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifUrl($provider, page: true)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'giphy page for klipy' => [GifProvider::Klipy, 'https://giphy.com/gifs/x'], + 'klipy page for giphy' => [GifProvider::Giphy, 'https://klipy.com/gifs/x'], +]); + +it('rejects unsafe or malformed page urls', function (mixed $url): void { + $validator = Validator::make(['url' => $url], ['url' => [new GifUrl(GifProvider::Klipy, page: true)]]); + + expect($validator->fails())->toBeTrue(); +})->with([ + 'not https' => ['http://klipy.com/gifs/x'], + 'with port' => ['https://klipy.com:8443/gifs/x'], + 'with userinfo' => ['https://user:pass@klipy.com/gifs/x'], + 'array value' => [['nope']], +]); + +it('labels each provider as the ui does', function (): void { + expect(GifProvider::Giphy->label())->toBe('GIPHY') + ->and(GifProvider::Klipy->label())->toBe('KLIPY'); +}); + +it('accepts only its own media hosts', function (GifProvider $provider, string $host, bool $allowed): void { + $matches = array_filter( + $provider->mediaHostPatterns(), + fn (string $pattern): bool => preg_match($pattern, $host) === 1, + ); + + expect($matches !== [])->toBe($allowed); +})->with([ + 'giphy media' => [GifProvider::Giphy, 'media2.giphy.com', true], + 'giphy api host' => [GifProvider::Giphy, 'api.giphy.com', false], + 'klipy static' => [GifProvider::Klipy, 'static.klipy.com', true], + 'klipy numbered static' => [GifProvider::Klipy, 'static2.klipy.com', true], + 'klipy api host' => [GifProvider::Klipy, 'api.klipy.com', false], +]); + +it('accepts only its own page hosts', function (GifProvider $provider, string $host, bool $allowed): void { + $matches = array_filter( + $provider->pageHostPatterns(), + fn (string $pattern): bool => preg_match($pattern, $host) === 1, + ); + + expect($matches !== [])->toBe($allowed); +})->with([ + 'giphy page' => [GifProvider::Giphy, 'giphy.com', true], + 'giphy www page' => [GifProvider::Giphy, 'www.giphy.com', true], + 'klipy page' => [GifProvider::Klipy, 'klipy.com', true], + 'klipy www page' => [GifProvider::Klipy, 'www.klipy.com', true], + 'giphy page against klipy' => [GifProvider::Klipy, 'giphy.com', false], +]); diff --git a/tsconfig.json b/tsconfig.json index e6397376c..df4c4d645 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -35,7 +35,7 @@ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + "allowImportingTsExtensions": true /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */, // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */