Skip to content

Commit 7ed22d7

Browse files
committed
Add gif comment feature
Powered by Klipy. Admins need to get an API key from Klipy to use this feature, see https://klipy.com/developers for more details.
1 parent 270319b commit 7ed22d7

30 files changed

Lines changed: 1800 additions & 318 deletions

app/Federation/ActivityBuilders/CreateActivityBuilder.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ private static function buildCommentObject(Profile $actor, Comment $comment): ar
235235
$commentObject = [
236236
'id' => $comment->getObjectUrl(),
237237
'type' => 'Note',
238-
'content' => AutoLinkerService::link($comment->caption),
238+
'content' => $comment->caption ? AutoLinkerService::link($comment->caption) : null,
239239
'attributedTo' => $actor->getActorId(),
240240
'inReplyTo' => $comment->video->getObjectUrl(),
241241
'url' => $comment->shareUrl(),
@@ -287,6 +287,20 @@ private static function buildCommentObject(Profile $actor, Comment $comment): ar
287287
}
288288
}
289289

290+
if ($comment->has_media) {
291+
$media = $comment->mediaAttachments->map(function ($m) {
292+
return [
293+
'type' => 'Document',
294+
'mediaType' => $m->mime_type,
295+
'url' => $m->remote_url,
296+
'name' => $m->description,
297+
'width' => $m->width,
298+
'height' => $m->height,
299+
];
300+
})->filter()->toArray();
301+
$commentObject['attachment'] = $media;
302+
}
303+
290304
$audience = Audience::getAudience($comment->visibility, $actor->getFollowersUrl(), $mentions);
291305
$commentObject['to'] = $audience['to'];
292306
$commentObject['cc'] = $audience['cc'];
@@ -336,7 +350,7 @@ private static function buildCommentReplyObject(Profile $actor, CommentReply $co
336350
$commentObject = [
337351
'id' => $comment->getObjectUrl(),
338352
'type' => 'Note',
339-
'content' => AutoLinkerService::link($comment->caption),
353+
'content' => $comment->caption ? AutoLinkerService::link($comment->caption) : null,
340354
'attributedTo' => $actor->getActorId(),
341355
'inReplyTo' => $comment->parent->getObjectUrl(),
342356
'url' => $comment->shareUrl(),

app/Http/Controllers/Api/AdminController.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -932,6 +932,8 @@ public function comments(Request $request)
932932

933933
$query = Comment::query();
934934

935+
$query = $query->with('mediaAttachments');
936+
935937
if ($local) {
936938
$query->whereNull('ap_id');
937939
}
@@ -974,7 +976,7 @@ public function comments(Request $request)
974976

975977
public function getComment(Request $request, $id)
976978
{
977-
$query = Comment::findOrFail($id);
979+
$query = Comment::with('mediaAttachments')->findOrFail($id);
978980

979981
return new CommentResource($query);
980982
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Services\ProfanityFilterService;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Support\Facades\Cache;
10+
use Klipy\Exceptions\KlipyApiException;
11+
use Klipy\Laravel\Facades\Klipy;
12+
13+
class KlipyController extends Controller
14+
{
15+
protected const TYPES = ['gifs', 'stickers', 'memes', 'clips'];
16+
17+
public function trending(Request $request, string $type): JsonResponse
18+
{
19+
abort_unless(! empty(config('klipy.api_key')), 400, 'Klipy API key missing');
20+
$user = $request->user();
21+
abort_unless((bool) $user->can_comment, 400, 'Cannot access this resource');
22+
$type = $this->resolveType($type);
23+
24+
$key = "loops:klipy:trending:{$type}:locale-en";
25+
26+
$payload = Cache::remember($key, now()->addHours(2), function () use ($type) {
27+
$response = Klipy::$type()->trending(
28+
perPage: 50,
29+
page: 1,
30+
locale: 'en',
31+
customerId: 1,
32+
);
33+
34+
return $this->normalize($response->raw);
35+
});
36+
37+
return response()->json($payload);
38+
}
39+
40+
public function search(Request $request, string $type, ProfanityFilterService $filter): JsonResponse
41+
{
42+
abort_unless(! empty(config('klipy.api_key')), 400, 'Klipy API key missing');
43+
$user = $request->user();
44+
abort_unless((bool) $user->can_comment, 400, 'Cannot access this resource');
45+
$type = $this->resolveType($type);
46+
47+
$query = trim((string) $request->input('q', ''));
48+
if ($query === '') {
49+
return response()->json($this->emptyPayload());
50+
}
51+
52+
if ($filter->contains($query)) {
53+
return response()->json($this->emptyPayload());
54+
}
55+
56+
$userId = $request->user()->id;
57+
$queryHash = md5($query);
58+
59+
$key = "loops:klipy:search:{$type}:{$userId}:{$queryHash}";
60+
61+
try {
62+
$payload = Cache::remember($key, now()->addHours(12), function () use ($type, $userId, $query) {
63+
$response = Klipy::$type()->search(
64+
query: $query,
65+
perPage: 50,
66+
page: 1,
67+
locale: 'en',
68+
customerId: $userId,
69+
);
70+
71+
return $this->normalize($response->raw);
72+
});
73+
} catch (KlipyApiException $e) {
74+
return response()->json([
75+
'message' => 'Klipy search failed.',
76+
'status' => $e->getStatusCode(),
77+
], 502);
78+
}
79+
80+
return response()->json($payload);
81+
}
82+
83+
protected function resolveType(string $type): string
84+
{
85+
abort_unless(in_array($type, self::TYPES, true), 404);
86+
87+
return $type;
88+
}
89+
90+
protected function normalize(array $raw): array
91+
{
92+
$data = $raw['data'] ?? [];
93+
$items = collect($data['data'] ?? [])
94+
->map(fn ($item) => $this->mapItem($item))
95+
->filter(fn ($i) => $i['slug'] !== null && $i['preview'] !== null)
96+
->values()
97+
->all();
98+
99+
return [
100+
'items' => $items,
101+
'page' => (int) ($data['current_page'] ?? 1),
102+
'per_page' => (int) ($data['per_page'] ?? count($items)),
103+
'has_next' => (bool) ($data['has_next'] ?? false),
104+
'meta' => $data['meta'] ?? null,
105+
];
106+
}
107+
108+
protected function mapItem(array $item): array
109+
{
110+
if (($item['type'] ?? null) === 'clip') {
111+
return $this->mapClipItem($item);
112+
}
113+
114+
$preview = $this->pickFormat($item, ['sm', 'md', 'xs', 'hd'], ['webp', 'gif']);
115+
$full = $this->pickFormat($item, ['md', 'hd', 'sm'], ['gif', 'webp']);
116+
$mp4 = $this->pickFormat($item, ['md', 'hd', 'sm'], ['mp4']);
117+
$webm = $this->pickFormat($item, ['md', 'hd', 'sm'], ['webm']);
118+
119+
$w = $full['width'] ?? $preview['width'] ?? 0;
120+
$h = $full['height'] ?? $preview['height'] ?? 0;
121+
122+
return [
123+
'id' => $item['id'] ?? null,
124+
'slug' => $item['slug'] ?? null,
125+
'title' => $item['title'] ?? '',
126+
'type' => $item['type'] ?? 'gif',
127+
'preview' => $preview,
128+
'full' => $full,
129+
'mp4' => $mp4,
130+
'webm' => $webm,
131+
'blur_preview' => $item['blur_preview'] ?? null,
132+
'width' => (int) $w,
133+
'height' => (int) $h,
134+
'is_ad' => (bool) ($item['is_ad'] ?? false),
135+
];
136+
}
137+
138+
protected function mapClipItem(array $item): array
139+
{
140+
$preview = $this->pickClipFormat($item, ['webp', 'gif']);
141+
$full = $this->pickClipFormat($item, ['gif', 'webp']);
142+
$mp4 = $this->pickClipFormat($item, ['mp4']);
143+
$webm = $this->pickClipFormat($item, ['webm']);
144+
145+
$w = $mp4['width'] ?? $full['width'] ?? $preview['width'] ?? 0;
146+
$h = $mp4['height'] ?? $full['height'] ?? $preview['height'] ?? 0;
147+
148+
return [
149+
'id' => $item['id'] ?? $item['slug'] ?? null,
150+
'slug' => $item['slug'] ?? null,
151+
'title' => $item['title'] ?? '',
152+
'type' => $item['type'] ?? 'clip',
153+
'preview' => $preview,
154+
'full' => $full,
155+
'mp4' => $mp4,
156+
'webm' => $webm,
157+
'blur_preview' => $item['blur_preview'] ?? null,
158+
'width' => (int) $w,
159+
'height' => (int) $h,
160+
'is_ad' => (bool) ($item['is_ad'] ?? false),
161+
];
162+
}
163+
164+
protected function pickClipFormat(array $item, array $formats): ?array
165+
{
166+
$file = $item['file'] ?? [];
167+
$meta = $item['file_meta'] ?? [];
168+
169+
foreach ($formats as $format) {
170+
$url = $file[$format] ?? null;
171+
if (! is_string($url) || $url === '') {
172+
continue;
173+
}
174+
175+
$dims = is_array($meta[$format] ?? null) ? $meta[$format] : [];
176+
177+
return [
178+
'url' => $url,
179+
'width' => (int) ($dims['width'] ?? 0),
180+
'height' => (int) ($dims['height'] ?? 0),
181+
'size' => 0,
182+
];
183+
}
184+
185+
return null;
186+
}
187+
188+
protected function pickFormat(array $item, array $sizes, array $formats): ?array
189+
{
190+
$file = $item['file'] ?? [];
191+
192+
foreach ($sizes as $size) {
193+
$bucket = $file[$size] ?? null;
194+
if (! is_array($bucket)) {
195+
continue;
196+
}
197+
198+
foreach ($formats as $format) {
199+
$entry = $bucket[$format] ?? null;
200+
if (is_array($entry) && ! empty($entry['url'])) {
201+
return [
202+
'url' => $entry['url'],
203+
'width' => (int) ($entry['width'] ?? 0),
204+
'height' => (int) ($entry['height'] ?? 0),
205+
'size' => (int) ($entry['size'] ?? 0),
206+
];
207+
}
208+
}
209+
}
210+
211+
return null;
212+
}
213+
214+
protected function emptyPayload(): array
215+
{
216+
return ['items' => [], 'page' => 1, 'per_page' => 0, 'has_next' => false];
217+
}
218+
}

app/Http/Controllers/Api/VideoController.php

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Http\Requests\DeleteVideoRequest;
88
use App\Http\Requests\GetMentionAutocomplete;
99
use App\Http\Requests\GetTagAutocomplete;
10+
use App\Http\Requests\StoreCommentMediaRequest;
1011
use App\Http\Requests\StoreCommentReplyUpdateRequest;
1112
use App\Http\Requests\StoreCommentRequest;
1213
use App\Http\Requests\StoreCommentUpdateRequest;
@@ -22,6 +23,7 @@
2223
use App\Http\Resources\VideoLikeResource;
2324
use App\Http\Resources\VideoRepostResource;
2425
use App\Http\Resources\VideoResource;
26+
use App\Jobs\Comment\CommentKlipyMediaShareTriggerJob;
2527
use App\Jobs\Federation\DeliverCommentLikeActivity;
2628
use App\Jobs\Federation\DeliverCommentReplyLikeActivity;
2729
use App\Jobs\Federation\DeliverUndoCommentLikeActivity;
@@ -51,6 +53,7 @@
5153
use App\Services\ActivityPubCacheService;
5254
use App\Services\ConfigService;
5355
use App\Services\FederationDispatcher;
56+
use App\Services\KlipyMediaSelector;
5457
use App\Services\LikeService;
5558
use App\Services\NotificationService;
5659
use App\Services\SanitizeService;
@@ -524,7 +527,7 @@ public function comments(Request $request, $id)
524527
return $this->error('Video not found or is unavailable or has comments disabled', 404);
525528
}
526529

527-
$comments = Comment::withTrashed()
530+
$comments = Comment::with('mediaAttachments')->withTrashed()
528531
->whereVideoId($video->id)
529532
->where('is_hidden', false)
530533
->orderByDesc('id')
@@ -542,7 +545,8 @@ public function showHiddenComments(Request $request, $id)
542545
return $this->error('Video not found or is unavailable or has comments disabled', 404);
543546
}
544547

545-
$comments = Comment::withTrashed()
548+
$comments = Comment::with('mediaAttachments')
549+
->withTrashed()
546550
->whereVideoId($video->id)
547551
->where('is_hidden', true)
548552
->orderByDesc('id')
@@ -654,6 +658,69 @@ public function storeComment(StoreCommentRequest $request, $vid)
654658
CommentResource::collection([$comment]);
655659
}
656660

661+
public function storeCommentMedia(StoreCommentMediaRequest $request, $vid)
662+
{
663+
$user = $request->user();
664+
$pid = $user->profile_id;
665+
$video = Video::published()->canComment()->find($vid);
666+
667+
if (! $video || $user->cannot('view', [Video::class, $video])) {
668+
return $this->error('Video not found or is unavailable or has comments disabled', 404);
669+
}
670+
671+
$body = $request->filled('comment') ? $this->purifyText($request->comment) : null;
672+
$klipy = $request->input('item');
673+
$type = $request->input('type');
674+
$klipyId = data_get($klipy, 'id', $klipy['slug']);
675+
676+
$picked = app(KlipyMediaSelector::class)->pick($klipy, $type);
677+
678+
$comment = new Comment;
679+
$comment->video_id = $vid;
680+
$comment->profile_id = $pid;
681+
$comment->caption = $body;
682+
$comment->status = 'active';
683+
$comment->save();
684+
685+
if ($body) {
686+
$comment->syncHashtagsFromCaption();
687+
$comment->syncMentionsFromCaption();
688+
}
689+
690+
$comment->mediaAttachments()->create([
691+
'profile_id' => $pid,
692+
'remote_url' => $picked['url'],
693+
'mime_type' => $picked['mime_type'],
694+
'width' => $picked['width'],
695+
'height' => $picked['height'],
696+
'description' => $klipy['title'],
697+
'provider' => 'klipy',
698+
'external_id' => $klipyId,
699+
'visibility' => 1,
700+
]);
701+
702+
$comment->recalculateMedia();
703+
704+
$config = app(ConfigService::class);
705+
if ($config->federation()) {
706+
app(FederationDispatcher::class)->dispatchCommentCreation($comment);
707+
}
708+
if ($config->pushNotifications() && $pid != $video->profile_id) {
709+
SendPushNotificationJob::dispatch_newVideoComment(
710+
profileId: $video->profile_id,
711+
videoId: $video->id,
712+
actorId: $pid,
713+
commentId: $comment->id,
714+
);
715+
}
716+
717+
CommentKlipyMediaShareTriggerJob::dispatch((string) $klipyId, $type, (string) $user->id);
718+
719+
$video->recalculateCommentsCount();
720+
721+
return CommentResource::collection([$comment->load('mediaAttachments')]);
722+
}
723+
657724
public function storeCommentUpdate(StoreCommentUpdateRequest $request, $vid)
658725
{
659726
$pid = $request->user()->profile_id;
@@ -740,8 +807,10 @@ public function deleteComment(Request $request, $vid, $id)
740807

741808
if ($comment->children_count) {
742809
$comment->update(['caption' => null, 'status' => 'deleted_by_user']);
810+
$comment->mediaAttachments()->delete();
743811
$comment->delete();
744812
} else {
813+
$comment->mediaAttachments()->delete();
745814
$comment->forceDelete();
746815
}
747816

0 commit comments

Comments
 (0)