diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 88d4be2e1e0434c20b31846c5f72ac2573f5e743..e59d33895f542ad5102cdaf289d785b892f0b9a4 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -771,12 +771,24 @@ $routes->group('@(:podcastHandle)', function ($routes): void { 'controller-method' => 'EpisodeController::comments/$1/$2', ], ]); - $routes->get('comments/(:uuid)', 'EpisodeController::comment/$1/$2/$3', [ + $routes->get('comments/(:uuid)', 'EpisodeCommentController::view/$1/$2/$3', [ 'as' => 'comment', + 'application/activity+json' => [ + 'controller-method' => 'EpisodeController::commentObject/$1/$2', + ], + 'application/podcast-activity+json' => [ + 'controller-method' => 'EpisodeController::commentObject/$1/$2', + ], + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams' => [ + 'controller-method' => 'EpisodeController::commentObject/$1/$2', + ], ]); - $routes->get('comments/(:uuid)/replies', 'EpisodeController::commentReplies/$1/$2/$3', [ + $routes->get('comments/(:uuid)/replies', 'EpisodeCommentController::replies/$1/$2/$3', [ 'as' => 'comment-replies', ]); + $routes->post('comments/(:uuid)/like', 'EpisodeCommentController::attemptLike/$1/$2/$3', [ + 'as' => 'comment-attempt-like', + ]); $routes->get('oembed.json', 'EpisodeController::oembedJSON/$1/$2', [ 'as' => 'episode-oembed-json', ]); diff --git a/app/Controllers/Admin/EpisodeController.php b/app/Controllers/Admin/EpisodeController.php index fea2490da04c44f62c9ce49afbf49961845244ca..665c8007c530b1774325c116d4b59af2d5735441 100644 --- a/app/Controllers/Admin/EpisodeController.php +++ b/app/Controllers/Admin/EpisodeController.php @@ -10,13 +10,13 @@ declare(strict_types=1); namespace App\Controllers\Admin; -use App\Entities\Comment; use App\Entities\Episode; +use App\Entities\EpisodeComment; use App\Entities\Image; use App\Entities\Location; use App\Entities\Podcast; use App\Entities\Post; -use App\Models\CommentModel; +use App\Models\EpisodeCommentModel; use App\Models\EpisodeModel; use App\Models\PodcastModel; use App\Models\PostModel; @@ -800,7 +800,7 @@ class EpisodeController extends BaseController $message = $this->request->getPost('message'); - $newComment = new Comment([ + $newComment = new EpisodeComment([ 'actor_id' => interact_as_actor_id(), 'episode_id' => $this->episode->id, 'message' => $message, @@ -808,7 +808,7 @@ class EpisodeController extends BaseController 'created_by' => user_id(), ]); - $commentModel = new CommentModel(); + $commentModel = new EpisodeCommentModel(); if ( ! $commentModel->addComment($newComment, true) ) { diff --git a/app/Controllers/EpisodeCommentController.php b/app/Controllers/EpisodeCommentController.php new file mode 100644 index 0000000000000000000000000000000000000000..4f503d39e1bf68df993df23a7d81e71765c5783e --- /dev/null +++ b/app/Controllers/EpisodeCommentController.php @@ -0,0 +1,173 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Controllers; + +use ActivityPub\Entities\Actor; +use ActivityPub\Objects\OrderedCollectionObject; +use ActivityPub\Objects\OrderedCollectionPage; +use Analytics\AnalyticsTrait; +use App\Controllers\Admin\BaseController; +use App\Entities\Episode; +use App\Entities\EpisodeComment; +use App\Entities\Podcast; +use App\Libraries\CommentObject; +use App\Models\EpisodeCommentModel; +use App\Models\EpisodeModel; +use App\Models\PodcastModel; +use CodeIgniter\Exceptions\PageNotFoundException; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\HTTP\Response; + +class EpisodeCommentController extends BaseController +{ + use AnalyticsTrait; + + protected Podcast $podcast; + + protected Actor $actor; + + protected Episode $episode; + + protected EpisodeComment $comment; + + public function _remap(string $method, string ...$params): mixed + { + if (count($params) < 3) { + throw PageNotFoundException::forPageNotFound(); + } + + if ( + ($podcast = (new PodcastModel())->getPodcastByHandle($params[0])) === null + ) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->podcast = $podcast; + $this->actor = $podcast->actor; + + if ( + ($episode = (new EpisodeModel())->getEpisodeBySlug($params[0], $params[1])) === null + ) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->episode = $episode; + + if ( + ($comment = (new EpisodeCommentModel())->getCommentById($params[2])) === null + ) { + throw PageNotFoundException::forPageNotFound(); + } + + $this->comment = $comment; + + unset($params[2]); + unset($params[1]); + unset($params[0]); + + return $this->{$method}(...$params); + } + + public function view(): string + { + // Prevent analytics hit when authenticated + if (! can_user_interact()) { + $this->registerPodcastWebpageHit($this->podcast->id); + } + + $cacheName = implode( + '_', + array_filter([ + 'page', + "comment#{$this->comment->id}", + service('request') + ->getLocale(), + can_user_interact() ? '_authenticated' : null, + ]), + ); + + if (! ($cachedView = cache($cacheName))) { + $data = [ + 'podcast' => $this->podcast, + 'actor' => $this->actor, + 'episode' => $this->episode, + 'comment' => $this->comment, + ]; + + // if user is logged in then send to the authenticated activity view + if (can_user_interact()) { + helper('form'); + return view('podcast/comment_authenticated', $data); + } + return view('podcast/comment', $data, [ + 'cache' => DECADE, + 'cache_name' => $cacheName, + ]); + } + + return $cachedView; + } + + /** + * @noRector ReturnTypeDeclarationRector + */ + public function commentObject(): Response + { + $commentObject = new CommentObject($this->comment); + + return $this->response + ->setContentType('application/json') + ->setBody($commentObject->toJSON()); + } + + public function replies(): Response + { + /** + * get comment replies + */ + $commentReplies = model('CommentModel', false) + ->where('in_reply_to_id', service('uuid')->fromString($this->comment->id)->getBytes()) + ->orderBy('created_at', 'ASC'); + + $pageNumber = (int) $this->request->getGet('page'); + + if ($pageNumber < 1) { + $commentReplies->paginate(12); + $pager = $commentReplies->pager; + $collection = new OrderedCollectionObject(null, $pager); + } else { + $paginatedReplies = $commentReplies->paginate(12, 'default', $pageNumber); + $pager = $commentReplies->pager; + + $orderedItems = []; + if ($paginatedReplies !== null) { + foreach ($paginatedReplies as $reply) { + $replyObject = new CommentObject($reply); + $orderedItems[] = $replyObject; + } + } + + $collection = new OrderedCollectionPage($pager, $orderedItems); + } + + return $this->response + ->setContentType('application/activity+json') + ->setBody($collection->toJSON()); + } + + public function attemptLike(): RedirectResponse + { + model('LikeModel') + ->toggleLike(interact_as_actor(), $this->comment); + + return redirect()->back(); + } +} diff --git a/app/Controllers/EpisodeController.php b/app/Controllers/EpisodeController.php index 4e44af57446d372f7b67008af797fa746add336f..c721fb6fb78a3ec153de40e005c68ab73a4f970e 100644 --- a/app/Controllers/EpisodeController.php +++ b/app/Controllers/EpisodeController.php @@ -15,10 +15,8 @@ use ActivityPub\Objects\OrderedCollectionPage; use Analytics\AnalyticsTrait; use App\Entities\Episode; use App\Entities\Podcast; -use App\Libraries\CommentObject; use App\Libraries\NoteObject; use App\Libraries\PodcastEpisode; -use App\Models\CommentModel; use App\Models\EpisodeModel; use App\Models\PodcastModel; use CodeIgniter\Database\BaseBuilder; @@ -256,57 +254,4 @@ class EpisodeController extends BaseController ->setHeader('Access-Control-Allow-Origin', '*') ->setBody($collection->toJSON()); } - - /** - * @noRector ReturnTypeDeclarationRector - */ - public function comment(string $commentId): Response - { - if ( - ($comment = (new CommentModel())->getCommentById($commentId)) === null - ) { - throw PageNotFoundException::forPageNotFound(); - } - - $commentObject = new CommentObject($comment); - - return $this->response - ->setContentType('application/json') - ->setBody($commentObject->toJSON()); - } - - public function commentReplies(string $commentId): Response - { - /** - * get comment replies - */ - $commentReplies = model('CommentModel', false) - ->where('in_reply_to_id', service('uuid')->fromString($commentId)->getBytes()) - ->orderBy('created_at', 'ASC'); - - $pageNumber = (int) $this->request->getGet('page'); - - if ($pageNumber < 1) { - $commentReplies->paginate(12); - $pager = $commentReplies->pager; - $collection = new OrderedCollectionObject(null, $pager); - } else { - $paginatedReplies = $commentReplies->paginate(12, 'default', $pageNumber); - $pager = $commentReplies->pager; - - $orderedItems = []; - if ($paginatedReplies !== null) { - foreach ($paginatedReplies as $reply) { - $replyObject = new CommentObject($reply); - $orderedItems[] = $replyObject; - } - } - - $collection = new OrderedCollectionPage($pager, $orderedItems); - } - - return $this->response - ->setContentType('application/activity+json') - ->setBody($collection->toJSON()); - } } diff --git a/app/Database/Migrations/2021-08-12-150000_add_comments.php b/app/Database/Migrations/2021-08-12-150000_add_episode_comments.php similarity index 79% rename from app/Database/Migrations/2021-08-12-150000_add_comments.php rename to app/Database/Migrations/2021-08-12-150000_add_episode_comments.php index 612d9f63439f251b276ea4a65f374ba9a87f42c0..1524d45679f32bcd84e4149387e6fc861d6bc3e8 100644 --- a/app/Database/Migrations/2021-08-12-150000_add_comments.php +++ b/app/Database/Migrations/2021-08-12-150000_add_episode_comments.php @@ -3,9 +3,9 @@ declare(strict_types=1); /** - * Class AddComments creates comments table in database + * Class AddEpisodeComments creates episode_comments table in database * - * @copyright 2020 Podlibre + * @copyright 2021 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ */ @@ -14,7 +14,7 @@ namespace App\Database\Migrations; use CodeIgniter\Database\Migration; -class AddComments extends Migration +class AddEpisodeComments extends Migration { public function up(): void { @@ -42,22 +42,16 @@ class AddComments extends Migration ], 'message' => [ 'type' => 'VARCHAR', - 'constraint' => 500, - 'null' => true, + 'constraint' => 5000, ], 'message_html' => [ 'type' => 'VARCHAR', - 'constraint' => 600, - 'null' => true, + 'constraint' => 6000, ], 'likes_count' => [ 'type' => 'INT', 'unsigned' => true, ], - 'dislikes_count' => [ - 'type' => 'INT', - 'unsigned' => true, - ], 'replies_count' => [ 'type' => 'INT', 'unsigned' => true, @@ -75,11 +69,11 @@ class AddComments extends Migration $this->forge->addForeignKey('episode_id', 'episodes', 'id', '', 'CASCADE'); $this->forge->addForeignKey('actor_id', 'activitypub_actors', 'id', '', 'CASCADE'); $this->forge->addForeignKey('created_by', 'users', 'id'); - $this->forge->createTable('comments'); + $this->forge->createTable('episode_comments'); } public function down(): void { - $this->forge->dropTable('comments'); + $this->forge->dropTable('episode_comments'); } } diff --git a/app/Database/Migrations/2021-08-12-160000_add_likes.php b/app/Database/Migrations/2021-08-12-160000_add_likes.php new file mode 100644 index 0000000000000000000000000000000000000000..a32dc75ef831e4698590d4dc03f0364b120c0725 --- /dev/null +++ b/app/Database/Migrations/2021-08-12-160000_add_likes.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/** + * Class AddLikes Creates likes table in database + * + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace ActivityPub\Database\Migrations; + +use CodeIgniter\Database\Migration; + +class AddLikes extends Migration +{ + public function up(): void + { + $this->forge->addField([ + 'actor_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'comment_id' => [ + 'type' => 'BINARY', + 'constraint' => 16, + ], + ]); + $this->forge->addField('`created_at` timestamp NOT NULL DEFAULT current_timestamp()'); + $this->forge->addPrimaryKey(['actor_id', 'comment_id']); + $this->forge->addForeignKey('actor_id', 'activitypub_actors', 'id', '', 'CASCADE'); + $this->forge->addForeignKey('comment_id', 'episode_comments', 'id', '', 'CASCADE'); + $this->forge->createTable('likes'); + } + + public function down(): void + { + $this->forge->dropTable('likes'); + } +} diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 7b976a2ec4982d42967cb100ac5fb8a68aa437f7..c01219dd8d6aeac9ccafc7b1390c3a4034d233ac 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -11,7 +11,7 @@ declare(strict_types=1); namespace App\Entities; use App\Libraries\SimpleRSSElement; -use App\Models\CommentModel; +use App\Models\EpisodeCommentModel; use App\Models\PersonModel; use App\Models\PodcastModel; use App\Models\PostModel; @@ -122,7 +122,7 @@ class Episode extends Entity protected ?array $posts = null; /** - * @var Comment[]|null + * @var EpisodeComment[]|null */ protected ?array $comments = null; @@ -402,7 +402,7 @@ class Episode extends Entity } /** - * @return Comment[] + * @return EpisodeComment[] */ public function getComments(): array { @@ -411,7 +411,7 @@ class Episode extends Entity } if ($this->comments === null) { - $this->comments = (new CommentModel())->getEpisodeComments($this->id); + $this->comments = (new EpisodeCommentModel())->getEpisodeComments($this->id); } return $this->comments; diff --git a/app/Entities/Comment.php b/app/Entities/EpisodeComment.php similarity index 77% rename from app/Entities/Comment.php rename to app/Entities/EpisodeComment.php index 6235802f8fed5b6cce13b6ac9b1dc90424cdbb79..db2622c1f7d17e5c98495072b7ebbae2921af30a 100644 --- a/app/Entities/Comment.php +++ b/app/Entities/EpisodeComment.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace App\Entities; +use App\Models\EpisodeCommentModel; use App\Models\EpisodeModel; use CodeIgniter\I18n\Time; use Michalsn\Uuid\UuidEntity; @@ -23,22 +24,28 @@ use RuntimeException; * @property int $actor_id * @property Actor|null $actor * @property string $in_reply_to_id - * @property Comment|null $reply_to_comment + * @property EpisodeComment|null $reply_to_comment * @property string $message * @property string $message_html * @property int $likes_count - * @property int $dislikes_count * @property int $replies_count * @property Time $created_at * @property int $created_by + * + * @property EpisodeComment[] $replies */ -class Comment extends UuidEntity +class EpisodeComment extends UuidEntity { protected ?Episode $episode = null; protected ?Actor $actor = null; - protected ?Comment $reply_to_comment = null; + protected ?EpisodeComment $reply_to_comment = null; + + /** + * @var EpisodeComment[]|null + */ + protected ?array $replies = null; /** * @var string[] @@ -57,7 +64,6 @@ class Comment extends UuidEntity 'message' => 'string', 'message_html' => 'string', 'likes_count' => 'integer', - 'dislikes_count' => 'integer', 'replies_count' => 'integer', 'created_by' => 'integer', 'is_from_post' => 'boolean', @@ -96,6 +102,22 @@ class Comment extends UuidEntity return $this->actor; } + /** + * @return EpisodeComment[] + */ + public function getReplies(): array + { + if ($this->id === null) { + throw new RuntimeException('Comment must be created before getting replies.'); + } + + if ($this->replies === null) { + $this->replies = (new EpisodeCommentModel())->getCommentReplies($this->id); + } + + return $this->replies; + } + public function setMessage(string $message): static { helper('activitypub'); diff --git a/app/Entities/Like.php b/app/Entities/Like.php new file mode 100644 index 0000000000000000000000000000000000000000..ab9f3893f77af168dda05563c111d7f572fc98b2 --- /dev/null +++ b/app/Entities/Like.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities; + +use Michalsn\Uuid\UuidEntity; + +/** + * @property int $actor_id + * @property string $comment_id + */ +class Like extends UuidEntity +{ + /** + * @var string[] + */ + protected $uuids = ['comment_id']; + + /** + * @var array<string, string> + */ + protected $casts = [ + 'actor_id' => 'integer', + 'comment_id' => 'string', + ]; +} diff --git a/app/Language/en/Comment.php b/app/Language/en/Comment.php index 7073865a09c5f8f42c901e02af2f802141be29f9..d3677dc7d2ca3e75bd8ed2ccbd508c4403f7eafe 100644 --- a/app/Language/en/Comment.php +++ b/app/Language/en/Comment.php @@ -16,11 +16,8 @@ return [ 'submit_reply' => 'Reply', ], 'like' => 'Like', - 'dislike' => 'Dislike', - 'replies' => '{numberOfReplies, plural, - one {# reply} - other {# replies} - }', + 'reply' => 'Reply', + 'view_replies' => 'View replies ({numberOfReplies})', 'block_actor' => 'Block user @{actorUsername}', 'block_domain' => 'Block domain @{actorDomain}', 'delete' => 'Delete comment', diff --git a/app/Language/fr/Comment.php b/app/Language/fr/Comment.php new file mode 100644 index 0000000000000000000000000000000000000000..ce5fab4f288b4dea5a39337862a31e278632d4d1 --- /dev/null +++ b/app/Language/fr/Comment.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'form' => [ + 'episode_message_placeholder' => 'Saisissez un commentaire...', + 'reply_to_placeholder' => 'Répondre à @{actorUsername}', + 'submit' => 'Envoyer !', + 'submit_reply' => 'Répondre', + ], + 'like' => 'J’aime', + 'reply' => 'Répondre', + 'replies' => '{numberOfReplies, plural, + one {# réponse} + other {# réponses} + }', + 'block_actor' => 'Bloquer l’utilisateur @{actorUsername}', + 'block_domain' => 'Bloquer le domaine @{actorDomain}', + 'delete' => 'Supprimer le commentaire', +]; diff --git a/app/Language/fr/Post.php b/app/Language/fr/Post.php index a12bdd6f25608cd1f330a1399882ea787e4af13d..2981a4be9a34fd3b9311c4d2e3b69c03f777213f 100644 --- a/app/Language/fr/Post.php +++ b/app/Language/fr/Post.php @@ -19,7 +19,7 @@ return [ 'Écrivez votre message pour l’épisode...', 'episode_url_placeholder' => 'URL de l’épisode', 'reply_to_placeholder' => 'Répondre à @{actorUsername}', - 'submit' => 'Envoyer!', + 'submit' => 'Envoyer !', 'submit_reply' => 'Répondre', ], 'favourites' => '{numberOfFavourites, plural, diff --git a/app/Libraries/ActivityPub/Models/FavouriteModel.php b/app/Libraries/ActivityPub/Models/FavouriteModel.php index 3e22785bce1cf697331970e0f822eae71c457632..c0c4e9f1c251dac0828ab9e80d68b03d6add7575 100644 --- a/app/Libraries/ActivityPub/Models/FavouriteModel.php +++ b/app/Libraries/ActivityPub/Models/FavouriteModel.php @@ -100,14 +100,12 @@ class FavouriteModel extends UuidModel ->where('id', service('uuid') ->fromString($post->id) ->getBytes()) ->decrement('favourites_count'); - $this->db - ->table('activitypub_favourites') - ->where([ - 'actor_id' => $actor->id, - 'post_id' => service('uuid') - ->fromString($post->id) - ->getBytes(), - ]) + $this->where([ + 'actor_id' => $actor->id, + 'post_id' => service('uuid') + ->fromString($post->id) + ->getBytes(), + ]) ->delete(); if ($registerActivity) { @@ -161,7 +159,7 @@ class FavouriteModel extends UuidModel } /** - * Adds or removes favourite from database and increments count + * Adds or removes favourite from database */ public function toggleFavourite(Actor $actor, Post $post): void { diff --git a/app/Libraries/CommentObject.php b/app/Libraries/CommentObject.php index d127bcc2d3662da3d3919af7c2076be5a24b1daa..0cbd39629dae5c1da0f019b8d02505b0eede8629 100644 --- a/app/Libraries/CommentObject.php +++ b/app/Libraries/CommentObject.php @@ -11,7 +11,7 @@ declare(strict_types=1); namespace App\Libraries; use ActivityPub\Core\ObjectType; -use App\Entities\Comment; +use App\Entities\EpisodeComment; class CommentObject extends ObjectType { @@ -23,7 +23,7 @@ class CommentObject extends ObjectType protected string $replies; - public function __construct(Comment $comment) + public function __construct(EpisodeComment $comment) { $this->id = $comment->uri; diff --git a/app/Models/CommentModel.php b/app/Models/EpisodeCommentModel.php similarity index 81% rename from app/Models/CommentModel.php rename to app/Models/EpisodeCommentModel.php index ba7215faf04079470277c2e586fb1ac523f13693..794dab0e6d3ab6875a0556d2958ca538efae7e4a 100644 --- a/app/Models/CommentModel.php +++ b/app/Models/EpisodeCommentModel.php @@ -11,22 +11,22 @@ declare(strict_types=1); namespace App\Models; use ActivityPub\Activities\CreateActivity; -use App\Entities\Comment; +use App\Entities\EpisodeComment; use App\Libraries\CommentObject; use CodeIgniter\Database\BaseBuilder; use Michalsn\Uuid\UuidModel; -class CommentModel extends UuidModel +class EpisodeCommentModel extends UuidModel { /** * @var string */ - protected $returnType = Comment::class; + protected $returnType = EpisodeComment::class; /** * @var string */ - protected $table = 'comments'; + protected $table = 'episode_comments'; /** * @var string[] @@ -45,7 +45,6 @@ class CommentModel extends UuidModel 'message', 'message_html', 'likes_count', - 'dislikes_count', 'replies_count', 'created_at', 'created_by', @@ -56,7 +55,7 @@ class CommentModel extends UuidModel */ protected $beforeInsert = ['setCommentId']; - public function getCommentById(string $commentId): ?Comment + public function getCommentById(string $commentId): ?EpisodeComment { $cacheName = "comment#{$commentId}"; if (! ($found = cache($cacheName))) { @@ -69,7 +68,7 @@ class CommentModel extends UuidModel return $found; } - public function addComment(Comment $comment, bool $registerActivity = false): string | false + public function addComment(EpisodeComment $comment, bool $registerActivity = false): string | false { $this->db->transStart(); // increment Episode's comments_count @@ -122,7 +121,9 @@ class CommentModel extends UuidModel /** * Retrieves all published posts for a given episode ordered by publication date * - * @return Comment[] + * @return EpisodeComment[] + * + * @noRector ReturnTypeDeclarationRector */ public function getEpisodeComments(int $episodeId): array { @@ -133,7 +134,7 @@ class CommentModel extends UuidModel $episodePostsReplies = $this->db->table('activitypub_posts') ->select( - 'id, uri, episode_id, actor_id, in_reply_to_id, message, message_html, favourites_count as likes_count, 0 as dislikes_count, replies_count, published_at as created_at, created_by, 1 as is_from_post' + 'id, uri, episode_id, actor_id, in_reply_to_id, message, message_html, favourites_count as likes_count, replies_count, published_at as created_at, created_by, 1 as is_from_post' ) ->whereIn('in_reply_to_id', function (BaseBuilder $builder) use (&$episodeId): BaseBuilder { return $builder->select('id') @@ -147,18 +148,25 @@ class CommentModel extends UuidModel $episodeComments . ' UNION ' . $episodePostsReplies . ' ORDER BY created_at ASC' ); - return $allEpisodeComments->getCustomResultObject($this->returnType); + // FIXME:? + // @phpstan-ignore-next-line + return $this->convertUuidFieldsToStrings( + $allEpisodeComments->getCustomResultObject($this->tempReturnType), + $this->tempReturnType + ); } /** * Retrieves all replies for a given comment * - * @return Comment[] + * @return EpisodeComment[] */ - public function getCommentReplies(int $episodeId, string $commentId): array + public function getCommentReplies(string $commentId): array { // TODO: get all replies for a given comment - return $this->findAll(); + return $this->where('in_reply_to_id', $this->uuid->fromString($commentId)->getBytes()) + ->orderBy('created_at', 'ASC') + ->findAll(); } /** diff --git a/app/Models/LikeModel.php b/app/Models/LikeModel.php new file mode 100644 index 0000000000000000000000000000000000000000..01d49d1bd725474e1f055eb4f5110758ce1da6bd --- /dev/null +++ b/app/Models/LikeModel.php @@ -0,0 +1,165 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Models; + +use ActivityPub\Activities\LikeActivity; +use ActivityPub\Activities\UndoActivity; +use ActivityPub\Entities\Actor; +use App\Entities\EpisodeComment; +use App\Entities\Like; +use Michalsn\Uuid\UuidModel; + +class LikeModel extends UuidModel +{ + /** + * @var string + */ + protected $table = 'likes'; + + /** + * @var string[] + */ + protected $uuidFields = ['comment_id']; + + /** + * @var string[] + */ + protected $allowedFields = ['actor_id', 'comment_id']; + + /** + * @var string + */ + protected $returnType = Like::class; + + /** + * @var bool + */ + protected $useTimestamps = true; + + protected $updatedField; + + public function addLike(Actor $actor, EpisodeComment $comment, bool $registerActivity = true): void + { + $this->db->transStart(); + + $this->insert([ + 'actor_id' => $actor->id, + 'comment_id' => $comment->id, + ]); + + (new EpisodeCommentModel()) + ->where('id', service('uuid')->fromString($comment->id)->getBytes()) + ->increment('likes_count'); + + if ($registerActivity) { + $likeActivity = new LikeActivity(); + $likeActivity->set('actor', $actor->uri) + ->set('object', $comment->uri); + + $activityId = model('ActivityModel') + ->newActivity( + 'Like', + $actor->id, + null, + null, + $likeActivity->toJSON(), + $comment->created_at, + 'queued', + ); + + $likeActivity->set('id', url_to('activity', $actor->username, $activityId)); + + model('ActivityModel') + ->update($activityId, [ + 'payload' => $likeActivity->toJSON(), + ]); + } + + $this->db->transComplete(); + } + + public function removeLike(Actor $actor, EpisodeComment $comment, bool $registerActivity = true): void + { + $this->db->transStart(); + + (new EpisodeCommentModel()) + ->where('id', service('uuid') ->fromString($comment->id) ->getBytes()) + ->decrement('likes_count'); + + $this->where([ + 'actor_id' => $actor->id, + 'comment_id' => service('uuid') + ->fromString($comment->id) + ->getBytes(), + ]) + ->delete(); + + if ($registerActivity) { + $undoActivity = new UndoActivity(); + // FIXME: get like activity associated with the deleted like + $activity = model('ActivityModel') + ->where([ + 'type' => 'Like', + 'actor_id' => $actor->id, + ]) + ->first(); + + $likeActivity = new LikeActivity(); + $likeActivity + ->set('id', url_to('activity', $actor->username, $activity->id)) + ->set('actor', $actor->uri) + ->set('object', $comment->uri); + + $undoActivity + ->set('actor', $actor->uri) + ->set('object', $likeActivity); + + $activityId = model('ActivityModel') + ->newActivity( + 'Undo', + $actor->id, + null, + null, + $undoActivity->toJSON(), + $comment->created_at, + 'queued', + ); + + $undoActivity->set('id', url_to('activity', $actor->username, $activityId)); + + model('ActivityModel') + ->update($activityId, [ + 'payload' => $undoActivity->toJSON(), + ]); + } + + $this->db->transComplete(); + } + + /** + * Adds or removes likes from database + */ + public function toggleLike(Actor $actor, EpisodeComment $comment): void + { + if ( + $this->where([ + 'actor_id' => $actor->id, + 'comment_id' => service('uuid') + ->fromString($comment->id) + ->getBytes(), + ])->first() + ) { + $this->removeLike($actor, $comment); + } else { + $this->addLike($actor, $comment); + } + } +} diff --git a/app/Resources/icons/thumb-down.svg b/app/Resources/icons/thumb-down.svg deleted file mode 100644 index ff06be8ab1660324571b416b23b8c93a808ecd73..0000000000000000000000000000000000000000 --- a/app/Resources/icons/thumb-down.svg +++ /dev/null @@ -1,6 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <g> - <path fill="none" d="M0 0h24v24H0z"/> - <path d="M22 15h-3V3h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1zm-5.293 1.293l-6.4 6.4a.5.5 0 0 1-.654.047L8.8 22.1a1.5 1.5 0 0 1-.553-1.57L9.4 16H3a2 2 0 0 1-2-2v-2.104a2 2 0 0 1 .15-.762L4.246 3.62A1 1 0 0 1 5.17 3H16a1 1 0 0 1 1 1v11.586a1 1 0 0 1-.293.707z"/> - </g> -</svg> diff --git a/app/Resources/icons/thumb-up.svg b/app/Resources/icons/thumb-up.svg deleted file mode 100644 index f0d667027247a582b36b6cd825ad79e6d053d04d..0000000000000000000000000000000000000000 --- a/app/Resources/icons/thumb-up.svg +++ /dev/null @@ -1,6 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> - <g> - <path fill="none" d="M0 0h24v24H0z"/> - <path d="M2 9h3v12H2a1 1 0 0 1-1-1V10a1 1 0 0 1 1-1zm5.293-1.293l6.4-6.4a.5.5 0 0 1 .654-.047l.853.64a1.5 1.5 0 0 1 .553 1.57L14.6 8H21a2 2 0 0 1 2 2v2.104a2 2 0 0 1-.15.762l-3.095 7.515a1 1 0 0 1-.925.619H8a1 1 0 0 1-1-1V8.414a1 1 0 0 1 .293-.707z"/> - </g> -</svg> diff --git a/app/Views/podcast/_partials/comment.php b/app/Views/podcast/_partials/comment.php index 587df06942531685ba09fde1cd304c4b066fc2de..89ae77007117933dc66567aab740faaf23be509e 100644 --- a/app/Views/podcast/_partials/comment.php +++ b/app/Views/podcast/_partials/comment.php @@ -1,7 +1,7 @@ <article class="relative z-10 flex w-full px-4 py-2 rounded-2xl"> <img src="<?= $comment->actor->avatar_image_url ?>" alt="<?= $comment->display_name ?>" class="w-12 h-12 mr-4 rounded-full" /> <div class="flex-1"> - <header class="w-full mb-2"> + <header class="w-full mb-2 text-sm"> <a href="<?= $comment->actor ->uri ?>" class="flex items-baseline hover:underline" <?= $comment->actor->is_local ? '' @@ -17,27 +17,10 @@ </a> </header> <div class="mb-2 post-content"><?= $comment->message_html ?></div> - <div class="inline-flex gap-x-4"> - <?= anchor_popup( - route_to('comment-remote-action', $podcast->handle, $episode->slug, $comment->id, 'like'), - icon('thumb-up', 'text-lg mr-1 text-gray-400 group-hover:text-gray-600') . 0, - [ - 'class' => 'inline-flex items-center hover:underline group', - 'width' => 420, - 'height' => 620, - 'title' => lang('Comment.like'), - ], - ) ?> - <?= anchor_popup( - route_to('comment-remote-action', $podcast->handle, $episode->slug, $comment->id, 'dislike'), - icon('thumb-down', 'text-lg text-gray-400 group-hover:text-gray-600'), - [ - 'class' => 'inline-flex items-center hover:underline group', - 'width' => 420, - 'height' => 620, - 'title' => lang('Comment.dislike'), - ], - ) ?> - </div> + <?php if ($comment->is_from_post): ?> + <?= $this->include('podcast/_partials/comment_actions_from_post') ?> + <?php else: ?> + <?= $this->include('podcast/_partials/comment_actions') ?> + <?php endif; ?> </div> </article> diff --git a/app/Views/podcast/_partials/comment_actions.php b/app/Views/podcast/_partials/comment_actions.php new file mode 100644 index 0000000000000000000000000000000000000000..cb92fda5b90e4bb8ec867d762f780aa009328498 --- /dev/null +++ b/app/Views/podcast/_partials/comment_actions.php @@ -0,0 +1,24 @@ +<footer> + <form action="<?= route_to('comment-attempt-like', interact_as_actor()->username, $episode->slug, $comment->id) ?>" method="POST" class="flex items-center gap-x-4"> + <button type="submit" name="action" value="favourite" class="inline-flex items-center hover:underline group" title="<?= lang( + 'Comment.like', + [ + 'numberOfLikes' => $comment->likes_count, + ], + ) ?>"><?= icon('heart', 'text-xl mr-1 text-gray-400 group-hover:text-red-600') . $comment->likes_count ?></button> + <?= button( + lang('Comment.reply'), + route_to('comment', $podcast->handle, $episode->slug, $comment->id), + [ + 'size' => 'small', + ], + ) ?> + </form> + <?php if($comment->replies_count): ?> + <?= anchor( + route_to('comment', $podcast->handle, $episode->slug, $comment->id), + icon('caret-down', 'text-xl mr-1') . lang('Comment.view_replies', ['numberOfReplies' => $comment->replies_count]), + ['class' => 'inline-flex items-center text-xs hover:underline'] + ) ?> + <?php endif; ?> +</footer> \ No newline at end of file diff --git a/app/Views/podcast/_partials/comment_actions_from_post.php b/app/Views/podcast/_partials/comment_actions_from_post.php new file mode 100644 index 0000000000000000000000000000000000000000..de10aa3b6f9de1c03b73861ddc254b217a5f4933 --- /dev/null +++ b/app/Views/podcast/_partials/comment_actions_from_post.php @@ -0,0 +1,24 @@ +<footer> + <form action="<?= route_to('post-attempt-action', interact_as_actor()->username, $comment->id) ?>" method="POST" class="flex items-center gap-x-4"> + <button type="submit" name="action" value="favourite" class="inline-flex items-center hover:underline group" title="<?= lang( + 'Comment.like', + [ + 'numberOfLikes' => $comment->likes_count, + ], + ) ?>"><?= icon('heart', 'text-xl mr-1 text-gray-400 group-hover:text-red-600') . $comment->likes_count ?></button> + <?= button( + lang('Comment.reply'), + route_to('post', $podcast->handle, $comment->id), + [ + 'size' => 'small', + ], + ) ?> + </form> + <?php if($comment->replies_count): ?> + <?= anchor( + route_to('post', $podcast->handle, $comment->id), + icon('caret-down', 'text-xl mr-1') . lang('Comment.view_replies', ['numberOfReplies' => $comment->replies_count]), + ['class' => 'inline-flex items-center text-xs hover:underline'] + ) ?> + <?php endif; ?> +</footer> \ No newline at end of file diff --git a/app/Views/podcast/_partials/comment_authenticated.php b/app/Views/podcast/_partials/comment_authenticated.php new file mode 100644 index 0000000000000000000000000000000000000000..75055677f368f61d3b1231d62652a17e7693783f --- /dev/null +++ b/app/Views/podcast/_partials/comment_authenticated.php @@ -0,0 +1,27 @@ +<article class="relative z-10 w-full bg-white shadow-md rounded-2xl"> + <header class="flex px-6 py-4"> + <img src="<?= $comment->actor + ->avatar_image_url ?>" alt="<?= $comment->actor->display_name ?>" class="w-12 h-12 mr-4 rounded-full" /> + <div class="flex flex-col min-w-0"> + <a href="<?= $comment->actor + ->uri ?>" class="flex items-baseline hover:underline" <?= $comment + ->actor->is_local + ? '' + : 'target="_blank" rel="noopener noreferrer"' ?>> + <span class="mr-2 font-semibold truncate"><?= $comment->actor + ->display_name ?></span> + <span class="text-sm text-gray-500 truncate">@<?= $comment->actor + ->username . + ($comment->actor->is_local + ? '' + : '@' . $comment->actor->domain) ?></span> + </a> + <a href="<?= route_to('comment', $podcast->handle, $episode->slug, $comment->id) ?>" + class="text-xs text-gray-500"> + <?= relative_time($comment->created_at) ?> + </a> + </div> + </header> + <div class="px-6 mb-4 post-content"><?= $comment->message_html ?></div> + <?= $this->include('podcast/_partials/comment_actions') ?> +</article> diff --git a/app/Views/podcast/_partials/comment_with_replies.php b/app/Views/podcast/_partials/comment_with_replies.php new file mode 100644 index 0000000000000000000000000000000000000000..fcd817d771ce3a642a3d8c7b7e34a70f85bfa483 --- /dev/null +++ b/app/Views/podcast/_partials/comment_with_replies.php @@ -0,0 +1,22 @@ +<?= $this->include('podcast/_partials/comment') ?> +<div class="-mt-2 overflow-hidden border-b border-l border-r comment-replies rounded-b-xl"> + +<div class="px-6 pt-8 pb-4 bg-gray-50"> +<?= anchor_popup( + route_to('comment-remote-action', $podcast->handle, $comment->id, 'reply'), + lang('comment.reply_to', ['actorUsername' => $comment->actor->username]), + [ + 'class' => + 'text-center justify-center font-semibold rounded-full shadow relative z-10 px-4 py-2 w-full bg-rose-600 text-white inline-flex items-center hover:bg-rose-700', + 'width' => 420, + 'height' => 620, + ], +) ?> +</div> + + +<?php foreach ($comment->replies as $reply): ?> + <?= view('podcast/_partials/comment', ['comment' => $reply]) ?> +<?php endforeach; ?> + +</div> diff --git a/app/Views/podcast/_partials/comment_with_replies_authenticated.php b/app/Views/podcast/_partials/comment_with_replies_authenticated.php new file mode 100644 index 0000000000000000000000000000000000000000..c74c07d4ef788f126c486d02793cfc18b141f8ff --- /dev/null +++ b/app/Views/podcast/_partials/comment_with_replies_authenticated.php @@ -0,0 +1,47 @@ +<?= $this->include('podcast/_partials/comment_authenticated') ?> +<div class="-mt-2 overflow-hidden border-b border-l border-r post-replies rounded-b-xl"> +<?= form_open( + route_to('comment-attempt-action', interact_as_actor()->username, $episode->slug, $comment->id), + [ + 'class' => 'bg-gray-50 flex px-6 pt-8 pb-4', + ], +) ?> +<img src="<?= interact_as_actor() + ->avatar_image_url ?>" alt="<?= interact_as_actor() + ->display_name ?>" class="w-12 h-12 mr-4 rounded-full ring-gray-50 ring-2" /> +<div class="flex flex-col flex-1"> +<?= form_textarea( + [ + 'id' => 'message', + 'name' => 'message', + 'class' => 'form-textarea mb-4 w-full', + 'required' => 'required', + 'placeholder' => lang('Comment.form.reply_to_placeholder', [ + 'actorUsername' => $comment->actor->username, + ]), + ], + old('message', '', false), + [ + 'rows' => 1, + ], +) ?> +<?= button( + lang('Comment.form.submit_reply'), + '', + ['variant' => 'primary', 'size' => 'small'], + [ + 'type' => 'submit', + 'class' => 'self-end', + 'name' => 'action', + 'value' => 'reply', + ], +) ?> +</div> +<?= form_close() ?> + +<?php foreach ($comment->replies as $reply): ?> + <?= view('podcast/_partials/comment_authenticated', [ + 'comment' => $reply, + ]) ?> +<?php endforeach; ?> +</div> diff --git a/app/Views/podcast/_partials/post.php b/app/Views/podcast/_partials/post.php index 7ca262668c9135d3736cd14fcfd8d9cf8fdee0cd..0b04004c938b19fb43af521a8e20fe5f2e393799 100644 --- a/app/Views/podcast/_partials/post.php +++ b/app/Views/podcast/_partials/post.php @@ -1,7 +1,7 @@ <article class="relative z-10 w-full bg-white shadow rounded-2xl"> <header class="flex px-6 py-4"> <img src="<?= $post->actor - ->avatar_image_url ?>" alt="<?= $post->display_name ?>" class="w-12 h-12 mr-4 rounded-full" /> + ->avatar_image_url ?>" alt="<?= $post->actor->display_name ?>" class="w-12 h-12 mr-4 rounded-full" /> <div class="flex flex-col min-w-0"> <a href="<?= $post->actor ->uri ?>" class="flex items-baseline hover:underline" <?= $post diff --git a/app/Views/podcast/_partials/post_authenticated.php b/app/Views/podcast/_partials/post_authenticated.php index 1ec49dedef1710da6feafb1240908283bbc484e0..bb4ac6b4a02fc3a8debff5c3ca81fbadb5c68d2d 100644 --- a/app/Views/podcast/_partials/post_authenticated.php +++ b/app/Views/podcast/_partials/post_authenticated.php @@ -1,7 +1,7 @@ <article class="relative z-10 w-full bg-white shadow-md rounded-2xl"> <header class="flex px-6 py-4"> <img src="<?= $post->actor - ->avatar_image_url ?>" alt="<?= $post->display_name ?>" class="w-12 h-12 mr-4 rounded-full" /> + ->avatar_image_url ?>" alt="<?= $post->actor->display_name ?>" class="w-12 h-12 mr-4 rounded-full" /> <div class="flex flex-col min-w-0"> <a href="<?= $post->actor ->uri ?>" class="flex items-baseline hover:underline" <?= $post diff --git a/app/Views/podcast/comment.php b/app/Views/podcast/comment.php new file mode 100644 index 0000000000000000000000000000000000000000..9e604078cd64ee6dc04ad75d94177443eca26ae1 --- /dev/null +++ b/app/Views/podcast/comment.php @@ -0,0 +1,38 @@ +<?= $this->extend('podcast/_layout') ?> + +<?= $this->section('meta-tags') ?> + <title><?= lang('Comment.title', [ + 'actorDisplayName' => $comment->actor->display_name, + ]) ?></title> + <meta name="description" content="<?= $comment->message ?>"/> + <meta property="og:title" content="<?= lang('Comment.title', [ + 'actorDisplayName' => $comment->actor->display_name, + ]) ?>"/> + <meta property="og:locale" content="<?= service( + 'request', + )->getLocale() ?>" /> + <meta property="og:site_name" content="<?= $comment->actor->display_name ?>" /> + <meta property="og:url" content="<?= current_url() ?>" /> + <meta property="og:image" content="<?= $comment->actor->avatar_image_url ?>" /> + <meta property="og:description" content="<?= $comment->message ?>" /> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> +<div class="max-w-2xl px-6 mx-auto"> + <nav class="py-3"> + <a href="<?= route_to('episode', $podcast->handle, $episode->slug) ?>" + class="inline-flex items-center px-4 py-2 text-sm"><?= icon( + 'arrow-left', + 'mr-2 text-lg', + ) . + lang('Comment.back_to_episode', [ + 'actor' => $comment->actor->display_name, + ]) ?></a> + </nav> + <div class="pb-12"> + <?= $this->include('podcast/_partials/comment_with_replies') ?> + </div> +</div> + +<?= $this->endSection() +?> diff --git a/app/Views/podcast/comment_authenticated.php b/app/Views/podcast/comment_authenticated.php new file mode 100644 index 0000000000000000000000000000000000000000..89965611c2e09aa0c78f44a3402c47e0513077ab --- /dev/null +++ b/app/Views/podcast/comment_authenticated.php @@ -0,0 +1,40 @@ +<?= $this->extend('podcast/_layout_authenticated') ?> + +<?= $this->section('meta-tags') ?> + <title><?= lang('Comment.title', [ + 'actorDisplayName' => $comment->actor->display_name, + ]) ?></title> + <meta name="description" content="<?= $comment->message ?>"/> + <meta property="og:title" content="<?= lang('Comment.title', [ + 'actorDisplayName' => $comment->actor->display_name, + ]) ?>"/> + <meta property="og:locale" content="<?= service( + 'request', + )->getLocale() ?>" /> + <meta property="og:site_name" content="<?= $comment->actor->display_name ?>" /> + <meta property="og:url" content="<?= current_url() ?>" /> + <meta property="og:image" content="<?= $comment->actor->avatar_image_url ?>" /> + <meta property="og:description" content="<?= $comment->message ?>" /> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> +<div class="max-w-2xl px-6 mx-auto"> + <nav class="py-3"> + <a href="<?= route_to('episode', $podcast->handle, $episode->slug) ?>" + class="inline-flex items-center px-4 py-2 text-sm"><?= icon( + 'arrow-left', + 'mr-2 text-lg', + ) . + lang('Comment.back_to_episode', [ + 'actor' => $comment->actor->display_name, + ]) ?></a> + </nav> + <div class="pb-12"> + <?= $this->include( + 'podcast/_partials/comment_with_replies_authenticated', + ) ?> + </div> +</div> + +<?= $this->endSection() +?> diff --git a/app/Views/podcast/episode_authenticated.php b/app/Views/podcast/episode_authenticated.php index b62bb0ada6a91ab98b060f0e9dfd7d2a275a2cf9..59e9b69b2441ab9ac8d0230837293860bf8caece 100644 --- a/app/Views/podcast/episode_authenticated.php +++ b/app/Views/podcast/episode_authenticated.php @@ -85,7 +85,7 @@ <div class="tab-panels"> <section id="comments" class="space-y-6 tab-panel"> <?= form_open(route_to('comment-attempt-create', $podcast->id, $episode->id), [ - 'class' => 'flex p-4 bg-white shadow rounded-xl', + 'class' => 'flex p-4', ]) ?> <?= csrf_field() ?> @@ -118,7 +118,6 @@ ) ?> </div> <?= form_close() ?> - <hr class="my-4 border border-pine-100"> <?php foreach ($episode->comments as $comment): ?> <?= view('podcast/_partials/comment', ['comment' => $comment]) ?> <?php endforeach; ?>