mirror of
https://code.castopod.org/adaures/castopod.git
synced 2024-09-27 20:21:59 +02:00
parent
c29c018c7a
commit
999999e3ef
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class AddNotifications Creates notifications table in database
|
||||||
|
*
|
||||||
|
* @copyright 2021 Ad Aures
|
||||||
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
|
* @link https://castopod.org/
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddNotifications extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'unsigned' => true,
|
||||||
|
'auto_increment' => true,
|
||||||
|
],
|
||||||
|
'actor_id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'unsigned' => true,
|
||||||
|
],
|
||||||
|
'target_actor_id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'unsigned' => true,
|
||||||
|
],
|
||||||
|
'post_id' => [
|
||||||
|
'type' => 'BINARY',
|
||||||
|
'constraint' => 16,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'activity_id' => [
|
||||||
|
'type' => 'BINARY',
|
||||||
|
'constraint' => 16,
|
||||||
|
],
|
||||||
|
'type' => [
|
||||||
|
'type' => 'ENUM',
|
||||||
|
'constraint' => ['like', 'follow', 'share', 'reply'],
|
||||||
|
],
|
||||||
|
'read_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tablesPrefix = config('Fediverse')
|
||||||
|
->tablesPrefix;
|
||||||
|
|
||||||
|
$this->forge->addPrimaryKey('id');
|
||||||
|
$this->forge->addForeignKey('actor_id', $tablesPrefix . 'actors', 'id', '', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('target_actor_id', $tablesPrefix . 'actors', 'id', '', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('post_id', $tablesPrefix . 'posts', 'id', '', 'CASCADE');
|
||||||
|
$this->forge->addForeignKey('activity_id', $tablesPrefix . 'activities', 'id', '', 'CASCADE');
|
||||||
|
$this->forge->createTable('notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('notifications');
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class AddActivitiesTrigger Creates activities trigger in database
|
||||||
|
*
|
||||||
|
* @copyright 2020 Ad Aures
|
||||||
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
|
* @link https://castopod.org/
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddNotificationsTrigger extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$activitiesTable = $this->db->prefixTable(config('Fediverse')->tablesPrefix . 'activities');
|
||||||
|
$notificationsTable = $this->db->prefixTable('notifications');
|
||||||
|
$createQuery = <<<CODE_SAMPLE
|
||||||
|
CREATE TRIGGER `{$activitiesTable}_after_insert`
|
||||||
|
AFTER INSERT ON `{$activitiesTable}`
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
-- only create notification if new incoming activity with NULL status is created
|
||||||
|
IF NEW.target_actor_id AND NEW.target_actor_id != NEW.actor_id AND NEW.status IS NULL THEN
|
||||||
|
IF NEW.type IN ( 'Create', 'Like', 'Announce', 'Follow' ) THEN
|
||||||
|
SET @type = (CASE
|
||||||
|
WHEN NEW.type = 'Create' THEN 'reply'
|
||||||
|
WHEN NEW.type = 'Like' THEN 'like'
|
||||||
|
WHEN NEW.type = 'Announce' THEN 'share'
|
||||||
|
WHEN NEW.type = 'Follow' THEN 'follow'
|
||||||
|
END);
|
||||||
|
INSERT INTO `{$notificationsTable}` (`actor_id`, `target_actor_id`, `post_id`, `activity_id`, `type`, `created_at`, `updated_at`)
|
||||||
|
VALUES (NEW.actor_id, NEW.target_actor_id, NEW.post_id, NEW.id, @type, NEW.created_at, NEW.created_at);
|
||||||
|
ELSE
|
||||||
|
DELETE FROM `{$notificationsTable}`
|
||||||
|
WHERE `actor_id` = NEW.actor_id
|
||||||
|
AND `target_actor_id` = NEW.target_actor_id
|
||||||
|
AND ((`type` = (CASE WHEN NEW.type = 'Undo_Follow' THEN 'follow' END) AND `post_id` IS NULL)
|
||||||
|
OR (`type` = (CASE
|
||||||
|
WHEN NEW.type = 'Delete' THEN 'reply'
|
||||||
|
WHEN NEW.type = 'Undo_Like' THEN 'like'
|
||||||
|
WHEN NEW.type = 'Undo_Announce' THEN 'share'
|
||||||
|
END)
|
||||||
|
AND `post_id` = NEW.post_id));
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
CODE_SAMPLE;
|
||||||
|
$this->db->query($createQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$activitiesTable = $this->db->prefixTable(config('Fediverse')->tablesPrefix . 'activities');
|
||||||
|
$this->db->query("DROP TRIGGER IF EXISTS `{$activitiesTable}_after_insert`");
|
||||||
|
}
|
||||||
|
}
|
106
app/Entities/Notification.php
Normal file
106
app/Entities/Notification.php
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright 2020 Ad Aures
|
||||||
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
|
* @link https://castopod.org/
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Entities;
|
||||||
|
|
||||||
|
use Michalsn\Uuid\UuidEntity;
|
||||||
|
use Modules\Fediverse\Entities\Activity;
|
||||||
|
use Modules\Fediverse\Entities\Actor;
|
||||||
|
use Modules\Fediverse\Entities\Post;
|
||||||
|
use Modules\Fediverse\Models\ActorModel;
|
||||||
|
use Modules\Fediverse\Models\PostModel;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $actor_id
|
||||||
|
* @property Actor $actor
|
||||||
|
* @property int $target_actor_id
|
||||||
|
* @property Actor $target_actor
|
||||||
|
* @property string|null $post_id
|
||||||
|
* @property Post $post
|
||||||
|
* @property string $activity_id
|
||||||
|
* @property Activity $activity
|
||||||
|
* @property 'like'|'follow'|'share'|'reply' $type
|
||||||
|
* @property Time|null $read_at
|
||||||
|
* @property Time $created_at
|
||||||
|
* @property Time $updated_at
|
||||||
|
*/
|
||||||
|
class Notification extends UuidEntity
|
||||||
|
{
|
||||||
|
protected ?Actor $actor = null;
|
||||||
|
|
||||||
|
protected ?Actor $target_actor = null;
|
||||||
|
|
||||||
|
protected ?Post $post = null;
|
||||||
|
|
||||||
|
protected ?Activity $activity = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
protected $uuids = ['post_id', 'activity_id'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
protected $dates = ['read_at', 'created_at', 'updated_at'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'id' => 'integer',
|
||||||
|
'actor_id' => 'integer',
|
||||||
|
'target_actor_id' => 'integer',
|
||||||
|
'post_id' => '?string',
|
||||||
|
'activity_id' => 'string',
|
||||||
|
'type' => 'string',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getActor(): ?Actor
|
||||||
|
{
|
||||||
|
if ($this->actor_id === null) {
|
||||||
|
throw new RuntimeException('Notification must have an actor_id before getting actor.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->actor instanceof Actor) {
|
||||||
|
$this->actor = (new ActorModel())->getActorById($this->actor_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTargetActor(): ?Actor
|
||||||
|
{
|
||||||
|
if ($this->target_actor_id === null) {
|
||||||
|
throw new RuntimeException('Notification must have a target_actor_id before getting target actor.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->target_actor instanceof Actor) {
|
||||||
|
$this->target_actor = (new ActorModel())->getActorById($this->target_actor_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->target_actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPost(): ?Post
|
||||||
|
{
|
||||||
|
if ($this->post_id === null) {
|
||||||
|
throw new RuntimeException('Notification must have a post_id before getting post.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->post instanceof Post) {
|
||||||
|
$this->post = (new PostModel())->getPostById($this->post_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->post;
|
||||||
|
}
|
||||||
|
}
|
47
app/Models/NotificationModel.php
Normal file
47
app/Models/NotificationModel.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright 2021 Ad Aures
|
||||||
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
|
* @link https://castopod.org/
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Entities\Notification;
|
||||||
|
use Michalsn\Uuid\UuidModel;
|
||||||
|
|
||||||
|
class NotificationModel extends UuidModel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $table = 'notifications';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $returnType = Notification::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
protected $uuidFields = ['post_id', 'activity_id'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string[]
|
||||||
|
*/
|
||||||
|
protected $allowedFields = ['read_at'];
|
||||||
|
}
|
6
app/Resources/icons/notification-bell.svg
Normal file
6
app/Resources/icons/notification-bell.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<g>
|
||||||
|
<path fill="none" d="M0 0h24v24H0z"/>
|
||||||
|
<path d="M20 17h2v2H2v-2h2v-7a8 8 0 1 1 16 0v7zM9 21h6v2H9v-2z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 204 B |
6
app/Resources/icons/user-follow.svg
Normal file
6
app/Resources/icons/user-follow.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<g>
|
||||||
|
<path fill="none" d="M0 0h24v24H0z"/>
|
||||||
|
<path d="M13 14.062V22H4a8 8 0 0 1 9-7.938zM12 13c-3.315 0-6-2.685-6-6s2.685-6 6-6 6 2.685 6 6-2.685 6-6 6zm5.793 6.914l3.535-3.535 1.415 1.414-4.95 4.95-3.536-3.536 1.415-1.414 2.12 2.121z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 331 B |
@ -626,6 +626,19 @@ $routes->group(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Podcast notifications
|
||||||
|
$routes->group('notifications', function ($routes): void {
|
||||||
|
$routes->get('/', 'NotificationController::list/$1', [
|
||||||
|
'as' => 'notification-list',
|
||||||
|
]);
|
||||||
|
$routes->get('(:num)/mark-as-read', 'NotificationController::markAsRead/$1/$2', [
|
||||||
|
'as' => 'notification-mark-as-read',
|
||||||
|
]);
|
||||||
|
$routes->get('mark-all-as-read', 'NotificationController::markAllAsRead/$1', [
|
||||||
|
'as' => 'notification-mark-all-as-read',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
107
modules/Admin/Controllers/NotificationController.php
Normal file
107
modules/Admin/Controllers/NotificationController.php
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright 2020 Ad Aures
|
||||||
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
|
* @link https://castopod.org/
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Modules\Admin\Controllers;
|
||||||
|
|
||||||
|
use App\Entities\Notification;
|
||||||
|
use App\Entities\Podcast;
|
||||||
|
use App\Models\NotificationModel;
|
||||||
|
use App\Models\PodcastModel;
|
||||||
|
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||||
|
use CodeIgniter\HTTP\RedirectResponse;
|
||||||
|
use CodeIgniter\I18n\Time;
|
||||||
|
use Modules\Fediverse\Models\PostModel;
|
||||||
|
|
||||||
|
class NotificationController extends BaseController
|
||||||
|
{
|
||||||
|
protected Podcast $podcast;
|
||||||
|
|
||||||
|
protected Notification $notification;
|
||||||
|
|
||||||
|
public function _remap(string $method, string ...$params): mixed
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
($podcast = (new PodcastModel())->getPodcastById((int) $params[0])) === null
|
||||||
|
) {
|
||||||
|
throw PageNotFoundException::forPageNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->podcast = $podcast;
|
||||||
|
|
||||||
|
if (count($params) > 1) {
|
||||||
|
if (
|
||||||
|
! ($notification = (new NotificationModel())
|
||||||
|
->where([
|
||||||
|
'id' => $params[1],
|
||||||
|
])
|
||||||
|
->first())
|
||||||
|
) {
|
||||||
|
throw PageNotFoundException::forPageNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notification = $notification;
|
||||||
|
|
||||||
|
unset($params[1]);
|
||||||
|
unset($params[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->{$method}(...$params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(): string
|
||||||
|
{
|
||||||
|
$notifications = (new NotificationModel())->where('target_actor_id', $this->podcast->actor_id)
|
||||||
|
->orderBy('created_at', 'desc');
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'podcast' => $this->podcast,
|
||||||
|
'notifications' => $notifications->paginate(10),
|
||||||
|
'pager' => $notifications->pager,
|
||||||
|
];
|
||||||
|
|
||||||
|
replace_breadcrumb_params([
|
||||||
|
0 => $this->podcast->title,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return view('podcast/notifications', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsRead(): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->notification->read_at = new Time('now');
|
||||||
|
$notificationModel = new NotificationModel();
|
||||||
|
$notificationModel->update($this->notification->id, $this->notification);
|
||||||
|
|
||||||
|
if ($this->notification->post_id === null) {
|
||||||
|
return redirect()->route('podcast-activity', [esc($this->podcast->handle)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$post = (new PostModel())->getPostById($this->notification->post_id);
|
||||||
|
|
||||||
|
return redirect()->route(
|
||||||
|
'post',
|
||||||
|
[esc((new PodcastModel())->getPodcastByActorId($this->notification->actor_id)->handle), $post->id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAllAsRead(): RedirectResponse
|
||||||
|
{
|
||||||
|
$notifications = (new NotificationModel())->where('target_actor_id', $this->podcast->actor_id)
|
||||||
|
->where('read_at', null)
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
foreach ($notifications as $notification) {
|
||||||
|
$notification->read_at = new Time('now');
|
||||||
|
(new NotificationModel())->update($notification->id, $notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back();
|
||||||
|
}
|
||||||
|
}
|
@ -45,4 +45,5 @@ return [
|
|||||||
'soundbites' => 'soundbites',
|
'soundbites' => 'soundbites',
|
||||||
'video-clips' => 'video clips',
|
'video-clips' => 'video clips',
|
||||||
'embed' => 'embeddable player',
|
'embed' => 'embeddable player',
|
||||||
|
'notifications' => 'notifications',
|
||||||
];
|
];
|
||||||
|
19
modules/Admin/Language/en/Notifications.php
Normal file
19
modules/Admin/Language/en/Notifications.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright 2020 Ad Aures
|
||||||
|
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
|
||||||
|
* @link https://castopod.org/
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'Notifications',
|
||||||
|
'reply' => '{actor_username} replied to your post',
|
||||||
|
'favourite' => '{actor_username} favourited your post',
|
||||||
|
'reblog' => '{actor_username} shared your post',
|
||||||
|
'follow' => '{actor_username} started following {target_actor_username}',
|
||||||
|
'no_notifications' => 'No notifications',
|
||||||
|
'mark_all_as_read' => 'Mark all as read',
|
||||||
|
];
|
@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||||||
namespace Modules\Auth\Entities;
|
namespace Modules\Auth\Entities;
|
||||||
|
|
||||||
use App\Entities\Podcast;
|
use App\Entities\Podcast;
|
||||||
|
use App\Models\NotificationModel;
|
||||||
use App\Models\PodcastModel;
|
use App\Models\PodcastModel;
|
||||||
use Myth\Auth\Entities\User as MythAuthUser;
|
use Myth\Auth\Entities\User as MythAuthUser;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
@ -26,6 +27,7 @@ use RuntimeException;
|
|||||||
* @property string|null $podcast_role
|
* @property string|null $podcast_role
|
||||||
*
|
*
|
||||||
* @property Podcast[] $podcasts All podcasts the user is contributing to
|
* @property Podcast[] $podcasts All podcasts the user is contributing to
|
||||||
|
* @property int[] $actorIdsWithUnreadNotifications Ids of the user's actors that have unread notifications
|
||||||
*/
|
*/
|
||||||
class User extends MythAuthUser
|
class User extends MythAuthUser
|
||||||
{
|
{
|
||||||
@ -34,6 +36,11 @@ class User extends MythAuthUser
|
|||||||
*/
|
*/
|
||||||
protected ?array $podcasts = null;
|
protected ?array $podcasts = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int[]|null
|
||||||
|
*/
|
||||||
|
protected ?array $actorIdsWithUnreadNotifications = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Array of field names and the type of value to cast them as when they are accessed.
|
* Array of field names and the type of value to cast them as when they are accessed.
|
||||||
*
|
*
|
||||||
@ -64,4 +71,25 @@ class User extends MythAuthUser
|
|||||||
|
|
||||||
return $this->podcasts;
|
return $this->podcasts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the ids of the user's actors that have unread notifications
|
||||||
|
*
|
||||||
|
* @return int[]
|
||||||
|
*/
|
||||||
|
public function getActorIdsWithUnreadNotifications(): array
|
||||||
|
{
|
||||||
|
if ($this->getPodcasts() === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unreadNotifications = (new NotificationModel())->whereIn(
|
||||||
|
'target_actor_id',
|
||||||
|
array_column($this->getPodcasts(), 'actor_id')
|
||||||
|
)
|
||||||
|
->where('read_at', null)
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
return array_column($unreadNotifications, 'target_actor_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -97,7 +97,7 @@ class ActivityModel extends BaseUuidModel
|
|||||||
'actor_id' => $actorId,
|
'actor_id' => $actorId,
|
||||||
'target_actor_id' => $targetActorId,
|
'target_actor_id' => $targetActorId,
|
||||||
'post_id' => $postId,
|
'post_id' => $postId,
|
||||||
'type' => $type,
|
'type' => $type === 'Undo' ? $type . '_' . (json_decode($payload, true))['object']['type'] : $type,
|
||||||
'payload' => $payload,
|
'payload' => $payload,
|
||||||
'scheduled_at' => $scheduledAt,
|
'scheduled_at' => $scheduledAt,
|
||||||
'status' => $taskStatus,
|
'status' => $taskStatus,
|
||||||
|
@ -15,20 +15,73 @@
|
|||||||
<?= icon('external-link', 'ml-1 opacity-60') ?>
|
<?= icon('external-link', 'ml-1 opacity-60') ?>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="inline-flex items-center h-full ml-auto">
|
||||||
type="button"
|
<button type="button" class="relative h-full px-2 focus:ring-accent focus:ring-inset" id="notifications-dropdown" data-dropdown="button" data-dropdown-target="notifications-dropdown-menu" aria-haspopup="true" aria-expanded="false">
|
||||||
class="inline-flex items-center h-full px-3 ml-auto text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2"
|
<?= icon('notification-bell', 'text-2xl opacity-80') ?>
|
||||||
id="my-account-dropdown"
|
<?php if (user()->actorIdsWithUnreadNotifications !== []): ?>
|
||||||
data-dropdown="button"
|
<span class="absolute top-2 right-2 w-2.5 h-2.5 bg-red-500 rounded-full border border-navigation-bg"></span>
|
||||||
data-dropdown-target="my-account-dropdown-menu"
|
<?php endif ?>
|
||||||
aria-haspopup="true"
|
</button>
|
||||||
aria-expanded="false"><div class="relative mr-1">
|
<?php
|
||||||
<?= icon('account-circle', 'text-3xl opacity-60') ?>
|
$notificationsTitle = lang('Notifications.title');
|
||||||
<?= user()
|
|
||||||
->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?>
|
$items = [
|
||||||
</div>
|
[
|
||||||
<?= esc(user()->username) ?>
|
'type' => 'html',
|
||||||
<?= icon('caret-down', 'ml-auto text-2xl') ?></button>
|
'content' => esc(<<<CODE_SAMPLE
|
||||||
|
<span class="px-4 my-2 text-xs font-semibold tracking-wider uppercase text-skin-muted">{$notificationsTitle}</span>
|
||||||
|
CODE_SAMPLE),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (user()->podcasts !== []) {
|
||||||
|
foreach (user()->podcasts as $userPodcast) {
|
||||||
|
$userPodcastTitle = esc($userPodcast->title);
|
||||||
|
|
||||||
|
$unreadNotificationDotDisplayClass = in_array($userPodcast->actor_id, user()->actorIdsWithUnreadNotifications, true) ? '' : 'hidden';
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'type' => 'link',
|
||||||
|
'title' => <<<CODE_SAMPLE
|
||||||
|
<div class="inline-flex items-center flex-1 text-sm align-middle">
|
||||||
|
<div class="relative">
|
||||||
|
<img src="{$userPodcast->cover->tiny_url}" class="w-6 h-6 mr-2 rounded-full" loading="lazy" />
|
||||||
|
<span class="absolute top-0 right-1 w-2.5 h-2.5 bg-red-500 rounded-full border border-background-elevated {$unreadNotificationDotDisplayClass}"></span>
|
||||||
|
</div>
|
||||||
|
<span class="max-w-xs truncate">{$userPodcastTitle}</span>
|
||||||
|
</div>
|
||||||
|
CODE_SAMPLE
|
||||||
|
,
|
||||||
|
'uri' => route_to('notification-list', $userPodcast->id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$noNotificationsText = lang('Notifications.no_notifications');
|
||||||
|
$items[] = [
|
||||||
|
'type' => 'html',
|
||||||
|
'content' => esc(<<<CODE_SAMPLE
|
||||||
|
<span class="mx-4 my-2 text-sm italic text-center text-skin-muted">{$noNotificationsText}</span>
|
||||||
|
CODE_SAMPLE),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<DropdownMenu id="notifications-dropdown-menu" labelledby="notifications-dropdown" items="<?= esc(json_encode($items)) ?>" placement="bottom"/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center h-full px-3 text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2"
|
||||||
|
id="my-account-dropdown"
|
||||||
|
data-dropdown="button"
|
||||||
|
data-dropdown-target="my-account-dropdown-menu"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"><div class="relative mr-1">
|
||||||
|
<?= icon('account-circle', 'text-3xl opacity-60') ?>
|
||||||
|
<?= user()
|
||||||
|
->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?>
|
||||||
|
</div>
|
||||||
|
<?= esc(user()->username) ?>
|
||||||
|
<?= icon('caret-down', 'ml-auto text-2xl') ?></button>
|
||||||
|
</div>
|
||||||
<?php
|
<?php
|
||||||
$interactButtons = '';
|
$interactButtons = '';
|
||||||
foreach (user()->podcasts as $userPodcast) {
|
foreach (user()->podcasts as $userPodcast) {
|
||||||
|
104
themes/cp_admin/podcast/notifications.php
Normal file
104
themes/cp_admin/podcast/notifications.php
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<?= $this->extend('_layout') ?>
|
||||||
|
|
||||||
|
<?= $this->section('title') ?>
|
||||||
|
<?= lang('Notifications.title') ?>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('pageTitle') ?>
|
||||||
|
<?= lang('Notifications.title') ?>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('headerRight') ?>
|
||||||
|
<Button uri="<?= route_to('notification-mark-all-as-read', $podcast->actor_id) ?>" variant="primary"><?= lang('Notifications.mark_all_as_read') ?></Button>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<?php if ($notifications === []): ?>
|
||||||
|
<div class="text-sm italic text-center text-skin-muted"><?= lang('Notifications.no_notifications') ?></div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="-mx-2 -mt-8 border-b divide-y md:-mx-12">
|
||||||
|
<?php
|
||||||
|
foreach ($notifications as $notification):
|
||||||
|
$backgroundColor = $notification->read_at === null ? 'bg-heading-background' : 'bg-base';
|
||||||
|
?>
|
||||||
|
<div class="py-3 hover:bg-white px-4 <?= $backgroundColor ?> group">
|
||||||
|
<?php
|
||||||
|
$post = $notification->post_id !== null ? $notification->post : null;
|
||||||
|
|
||||||
|
$actorUsername = '@' . esc($notification->actor
|
||||||
|
->username) .
|
||||||
|
($notification->actor->is_local
|
||||||
|
? ''
|
||||||
|
: '@' . esc($notification->actor->domain));
|
||||||
|
|
||||||
|
$actorUsernameHtml = <<<CODE_SAMPLE
|
||||||
|
<strong class="break-all">{$actorUsername}</strong>
|
||||||
|
CODE_SAMPLE;
|
||||||
|
|
||||||
|
$targetActorUsername = '@' . esc($notification->target_actor->username);
|
||||||
|
|
||||||
|
$targetActorUsernameHtml = <<<CODE_SAMPLE
|
||||||
|
<strong class="break-all">{$targetActorUsername}</strong>
|
||||||
|
CODE_SAMPLE;
|
||||||
|
|
||||||
|
$notificationTitle = match ($notification->type) {
|
||||||
|
'reply' => lang('Notifications.reply', [
|
||||||
|
'actor_username' => $actorUsernameHtml,
|
||||||
|
], null, false),
|
||||||
|
'like' => lang('Notifications.favourite', [
|
||||||
|
'actor_username' => $actorUsernameHtml,
|
||||||
|
], null, false),
|
||||||
|
'share' => lang('Notifications.reblog', [
|
||||||
|
'actor_username' => $actorUsernameHtml,
|
||||||
|
], null, false),
|
||||||
|
'follow' => lang('Notifications.follow', [
|
||||||
|
'actor_username' => $actorUsernameHtml,
|
||||||
|
'target_actor_username' => $targetActorUsernameHtml,
|
||||||
|
], null, false),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
$notificationContent = $post !== null ? $post->message_html : null;
|
||||||
|
|
||||||
|
$postLink = $post !== null ? route_to('post', esc($podcast->handle), $post->id) : route_to('podcast-activity', esc($podcast->handle));
|
||||||
|
$link = $notification->read_at !== null ? $postLink : route_to('notification-mark-as-read', $podcast->id, $notification->id);
|
||||||
|
?>
|
||||||
|
<a href="<?= $link ?>">
|
||||||
|
<div class="flex items-start md:items-center">
|
||||||
|
<div class="flex items-center shrink-0">
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full <?= $notification->read_at === null ? '' : 'invisible' ?>"></span>
|
||||||
|
<div class="relative ml-4">
|
||||||
|
<img src="<?= $notification->actor->avatar_image_url ?>" alt="<?= esc($notification->actor->display_name) ?>" class="rounded-full shadow-inner w-14 h-14 aspect-square" loading="lazy" />
|
||||||
|
<span class="absolute bottom-0 w-6 h-6 rounded-full -right-2.5 flex justify-center items-center <?= $backgroundColor ?> group-hover:bg-white">
|
||||||
|
<?php
|
||||||
|
$icon = match ($notification->type) {
|
||||||
|
'reply' => icon('chat', 'text-sky-500 text-base'),
|
||||||
|
'like' => icon('heart', 'text-rose-500 text-base'),
|
||||||
|
'share' => icon('repeat', 'text-green-500 text-base'),
|
||||||
|
'follow' => icon('user-follow', 'text-violet-500 text-base'),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<?= $icon ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 md:flex md:items-center grow">
|
||||||
|
<div class="grow">
|
||||||
|
<?= $notificationTitle ?>
|
||||||
|
<?php if ($notificationContent !== null): ?>
|
||||||
|
<p class="overflow-y-hidden text-skin-muted line-clamp-2 md:line-clamp-1"><?= $notificationContent ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-skin-muted md:ml-auto md:mr-4 whitespace-nowrap"><?= relative_time($notification->created_at) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6"><?= $pager->links() ?></div>
|
||||||
|
|
||||||
|
<?php endif ?>
|
||||||
|
|
||||||
|
<?= $this->endsection() ?>
|
@ -10,20 +10,71 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline-flex items-center h-full">
|
<div class="inline-flex items-center h-full">
|
||||||
<button
|
<button type="button" class="relative h-full px-2 focus:ring-accent focus:ring-inset" id="notifications-dropdown" data-dropdown="button" data-dropdown-target="notifications-dropdown-menu" aria-haspopup="true" aria-expanded="false">
|
||||||
type="button"
|
<?= icon('notification-bell', 'text-2xl opacity-80') ?>
|
||||||
class="inline-flex items-center h-full px-3 text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2"
|
<?php if (user()->actorIdsWithUnreadNotifications !== []): ?>
|
||||||
id="my-account-dropdown"
|
<span class="absolute top-2 right-2 w-2.5 h-2.5 bg-red-500 rounded-full border border-navigation-bg"></span>
|
||||||
data-dropdown="button"
|
<?php endif ?>
|
||||||
data-dropdown-target="my-account-dropdown-menu"
|
</button>
|
||||||
aria-haspopup="true"
|
<?php
|
||||||
aria-expanded="false"><div class="relative mr-1">
|
$notificationsTitle = lang('Notifications.title');
|
||||||
<?= icon('account-circle', 'text-3xl opacity-60') ?>
|
|
||||||
<?= user()
|
$items = [
|
||||||
->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?>
|
[
|
||||||
</div>
|
'type' => 'html',
|
||||||
<?= esc(user()->username) ?>
|
'content' => esc(<<<CODE_SAMPLE
|
||||||
<?= icon('caret-down', 'ml-auto text-2xl') ?></button>
|
<span class="px-4 my-2 text-xs font-semibold tracking-wider uppercase text-skin-muted">{$notificationsTitle}</span>
|
||||||
|
CODE_SAMPLE),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (user()->podcasts !== []) {
|
||||||
|
foreach (user()->podcasts as $userPodcast) {
|
||||||
|
$userPodcastTitle = esc($userPodcast->title);
|
||||||
|
|
||||||
|
$unreadNotificationDotDisplayClass = in_array($userPodcast->actor_id, user()->actorIdsWithUnreadNotifications, true) ? '' : 'hidden';
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'type' => 'link',
|
||||||
|
'title' => <<<CODE_SAMPLE
|
||||||
|
<div class="inline-flex items-center flex-1 text-sm align-middle">
|
||||||
|
<div class="relative">
|
||||||
|
<img src="{$userPodcast->cover->tiny_url}" class="w-6 h-6 mr-2 rounded-full" loading="lazy" />
|
||||||
|
<span class="absolute top-0 right-1 w-2.5 h-2.5 bg-red-500 rounded-full border border-background-elevated {$unreadNotificationDotDisplayClass}"></span>
|
||||||
|
</div>
|
||||||
|
<span class="max-w-xs truncate">{$userPodcastTitle}</span>
|
||||||
|
</div>
|
||||||
|
CODE_SAMPLE
|
||||||
|
,
|
||||||
|
'uri' => route_to('notification-list', $userPodcast->id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$noNotificationsText = lang('Notifications.no_notifications');
|
||||||
|
$items[] = [
|
||||||
|
'type' => 'html',
|
||||||
|
'content' => esc(<<<CODE_SAMPLE
|
||||||
|
<span class="mx-4 my-2 text-sm italic text-center text-skin-muted">{$noNotificationsText}</span>
|
||||||
|
CODE_SAMPLE),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<DropdownMenu id="notifications-dropdown-menu" labelledby="notifications-dropdown" items="<?= esc(json_encode($items)) ?>" placement="bottom"/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center h-full px-3 text-sm font-semibold focus:ring-inset focus:ring-accent gap-x-2"
|
||||||
|
id="my-account-dropdown"
|
||||||
|
data-dropdown="button"
|
||||||
|
data-dropdown-target="my-account-dropdown-menu"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"><div class="relative mr-1">
|
||||||
|
<?= icon('account-circle', 'text-3xl opacity-60') ?>
|
||||||
|
<?= user()
|
||||||
|
->podcasts === [] ? '' : '<img src="' . interact_as_actor()->avatar_image_url . '" class="absolute bottom-0 w-4 h-4 border rounded-full -right-1 border-navigation-bg" loading="lazy" />' ?>
|
||||||
|
</div>
|
||||||
|
<?= esc(user()->username) ?>
|
||||||
|
<?= icon('caret-down', 'ml-auto text-2xl') ?></button>
|
||||||
<?php
|
<?php
|
||||||
$interactButtons = '';
|
$interactButtons = '';
|
||||||
foreach (user()->podcasts as $userPodcast) {
|
foreach (user()->podcasts as $userPodcast) {
|
||||||
|
Loading…
Reference in New Issue
Block a user