From 9ce7b650dfb0f45db65ff784a26cd176ac402604 Mon Sep 17 00:00:00 2001 From: Sam Date: Sun, 31 Mar 2024 20:19:43 +0100 Subject: [PATCH] Mass message rework (#3494) * wip: mass messaging rework * WIP notifications * feat: mass messaging * chore: tidy force migration * chore: remove dev template * style: styleci fixes * fix: mass message bypass notification settings + mass message bypass purification * fix: typo in alerts page --- .gitignore | 1 + core/classes/Core/Alert.php | 47 +++- core/classes/Core/User.php | 41 ++- core/classes/DTO/UserNotificationData.php | 22 ++ core/classes/Queue/Task.php | 2 +- core/init.php | 1 + ...e_users_notification_preferences_table.php | 23 ++ ...84810_add_content_rich_to_alerts_table.php | 16 ++ ...dd_purify_bypass_colum_to_alerts_table.php | 15 + .../Default/core/announcements.tpl | 1 + .../Default/core/announcements_form.tpl | 1 + .../Default/core/emails_mass_message.tpl | 90 ------ .../Default/core/mass_message.tpl | 174 ++++++++++++ custom/templates/DefaultRevamp/css/custom.css | 19 +- custom/templates/DefaultRevamp/user/alert.tpl | 47 ++++ .../templates/DefaultRevamp/user/alerts.tpl | 16 +- .../user/notification_settings.tpl | 78 ++++++ modules/Core/classes/Core/Notification.php | 128 +++++++++ .../GenerateNotificationContentEvent.php | 32 +++ .../NotificationTypeNotFoundException.php | 3 + modules/Core/classes/Tasks/MassMessage.php | 58 ++++ modules/Core/classes/Tasks/SendEmail.php | 78 ++++++ .../hooks/GenerateNotificationContentHook.php | 23 ++ modules/Core/includes/constants/constants.php | 3 + modules/Core/init.php | 1 + modules/Core/language/en_UK.json | 30 +- modules/Core/module.php | 33 ++- modules/Core/pages/panel/announcements.php | 2 +- modules/Core/pages/panel/emails.php | 8 +- modules/Core/pages/panel/emails_errors.php | 4 +- .../Core/pages/panel/emails_mass_message.php | 134 --------- modules/Core/pages/panel/mass_message.php | 256 ++++++++++++++++++ modules/Core/pages/user/alerts.php | 172 ++++++++---- .../Core/pages/user/notification_settings.php | 141 ++++++++++ 34 files changed, 1386 insertions(+), 314 deletions(-) create mode 100644 core/classes/DTO/UserNotificationData.php create mode 100644 core/migrations/20230908180037_create_users_notification_preferences_table.php create mode 100644 core/migrations/20231121184810_add_content_rich_to_alerts_table.php create mode 100644 core/migrations/20240309204948_add_purify_bypass_colum_to_alerts_table.php delete mode 100644 custom/panel_templates/Default/core/emails_mass_message.tpl create mode 100644 custom/panel_templates/Default/core/mass_message.tpl create mode 100755 custom/templates/DefaultRevamp/user/alert.tpl create mode 100644 custom/templates/DefaultRevamp/user/notification_settings.tpl create mode 100644 modules/Core/classes/Core/Notification.php create mode 100644 modules/Core/classes/Events/GenerateNotificationContentEvent.php create mode 100644 modules/Core/classes/Exceptions/NotificationTypeNotFoundException.php create mode 100644 modules/Core/classes/Tasks/MassMessage.php create mode 100644 modules/Core/classes/Tasks/SendEmail.php create mode 100644 modules/Core/hooks/GenerateNotificationContentHook.php create mode 100644 modules/Core/includes/constants/constants.php delete mode 100644 modules/Core/pages/panel/emails_mass_message.php create mode 100644 modules/Core/pages/panel/mass_message.php create mode 100644 modules/Core/pages/user/notification_settings.php diff --git a/.gitignore b/.gitignore index 01e57edf56..e1d7fbc16b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /vendor /core/assets/vendor /uploads/avatars/** +/uploads/logos/** /node_modules/ composer.lock checksums.json diff --git a/core/classes/Core/Alert.php b/core/classes/Core/Alert.php index fb9d6d675b..2be1612b67 100644 --- a/core/classes/Core/Alert.php +++ b/core/classes/Core/Alert.php @@ -12,13 +12,16 @@ class Alert /** * Creates an alert for the specified user. * - * @param int $user_id Contains the ID of the user who we are creating the alert for. - * @param string $type Contains the alert type, eg 'tag' for user tagging. - * @param array $text_short Contains the alert text in short form for the dropdown. - * @param array $text Contains full information about the alert. - * @param string $link Contains link to view the alert, defaults to #. + * @deprecated Use Alert::send instead + * + * @param int $user_id Contains the ID of the user who we are creating the alert for. + * @param string $type Contains the alert type, eg 'tag' for user tagging. + * @param array $text_short Contains the alert text in short form for the dropdown. + * @param array $text Contains full information about the alert. + * @param ?string $link Contains link to view the alert, defaults to #. + * @param ?string $content Optional alert content. */ - public static function create(int $user_id, string $type, array $text_short, array $text, string $link = '#'): void + public static function create(int $user_id, string $type, array $text_short, array $text, ?string $link = '#', string $content = null): void { $db = DB::getInstance(); @@ -30,13 +33,41 @@ public static function create(int $user_id, string $type, array $text_short, arr $language = new Language($text_short['path'], $language->first()->short_code); + $text_short = $text_short['content'] ?? str_replace($text_short['replace'] ?? '', $text_short['replace_with'] ?? '', $language->get($text_short['file'], $text_short['term'])); + $text = $text['content'] ?? str_replace($text['replace'] ?? '', $text['replace_with'] ?? '', $language->get($text['file'], $text['term'])); + $db->insert('alerts', [ 'user_id' => $user_id, 'type' => $type, 'url' => $link, - 'content_short' => str_replace($text_short['replace'] ?? '', $text_short['replace_with'] ?? '', $language->get($text_short['file'], $text_short['term'])), - 'content' => str_replace($text['replace'] ?? '', $text['replace_with'] ?? '', $language->get($text['file'], $text['term'])), + 'content_short' => $text_short, + 'content' => $text, + 'content_rich' => $content, + 'created' => date('U'), + ]); + } + + /** + * Post a new alert to a user. + * + * @param int $userId + * @param string $title + * @param string $content + * @param string|null $link Optional link to redirect the user to on click + * @param bool $skipPurify If true the content will not be purified before displaying to user - use with care + * @return void + */ + public static function send(int $userId, string $title, string $content, ?string $link = '', bool $skipPurify = false) + { + DB::getInstance()->insert('alerts', [ + 'user_id' => $userId, + 'type' => 'alert', + 'url' => $link ?? '', + 'content_short' => $title, // Column maintained for legacy reasons + 'content' => $title, + 'content_rich' => $content, 'created' => date('U'), + 'bypass_purify' => $skipPurify, ]); } diff --git a/core/classes/Core/User.php b/core/classes/Core/User.php index 3dd130f8ea..27f04ec49d 100644 --- a/core/classes/Core/User.php +++ b/core/classes/Core/User.php @@ -6,7 +6,7 @@ * @author Samerton * @author Partydragen * @author Aberdeener - * @version 2.1.2 + * @version 2.2.0 * @license MIT */ class User @@ -1122,4 +1122,43 @@ public function savePlaceholders(int $server_id, array $placeholders): void ]); } } + + /** + * Get user's notification preferences. + * + * TODO: return type (PHP 8) + * + * @param string $type Optional type of notification to filter by + * + * @return UserNotificationData|UserNotificationData[]|null + */ + public function getNotificationPreferences(string $type = '') + { + if ($this->exists()) { + $where = 'user_id = ?'; + $whereVars = [$this->data()->id]; + + if (!empty($type)) { + $where .= ' AND `type` = ?'; + $whereVars[] = $type; + } + + $this->_db->get( + <<_db->first(); + } + + return $this->_db->results(); + } + + return null; + } } diff --git a/core/classes/DTO/UserNotificationData.php b/core/classes/DTO/UserNotificationData.php new file mode 100644 index 0000000000..54fb9460aa --- /dev/null +++ b/core/classes/DTO/UserNotificationData.php @@ -0,0 +1,22 @@ +alert = $row->alert; + $this->email = $row->email; + $this->type = $row->type; + } +} diff --git a/core/classes/Queue/Task.php b/core/classes/Queue/Task.php index 92dddd54f6..0e6dd5c570 100644 --- a/core/classes/Queue/Task.php +++ b/core/classes/Queue/Task.php @@ -157,7 +157,7 @@ public function fromId(int $id): ?Task $this->_scheduledFor = $task->scheduled_for; $this->_status = $task->status; $this->_task = $task->task; - $this->_userId = $task->userId; + $this->_userId = $task->user_id; return $this; } diff --git a/core/init.php b/core/init.php index 49a29c723d..06f4473256 100644 --- a/core/init.php +++ b/core/init.php @@ -388,6 +388,7 @@ $cc_nav->add('cc_alerts', $language->get('user', 'alerts'), URL::build('/user/alerts')); $cc_nav->add('cc_messaging', $language->get('user', 'messaging'), URL::build('/user/messaging')); $cc_nav->add('cc_connections', $language->get('user', 'connections'), URL::build('/user/connections')); + $cc_nav->add('cc_notification_settings', $language->get('user', 'notification_settings'), URL::build('/user/notification_settings')); $cc_nav->add('cc_settings', $language->get('user', 'profile_settings'), URL::build('/user/settings')); // Placeholders enabled? diff --git a/core/migrations/20230908180037_create_users_notification_preferences_table.php b/core/migrations/20230908180037_create_users_notification_preferences_table.php new file mode 100644 index 0000000000..f5a077bce4 --- /dev/null +++ b/core/migrations/20230908180037_create_users_notification_preferences_table.php @@ -0,0 +1,23 @@ +table('nl2_users_notification_preferences'); + + $table + ->addColumn('user_id', 'integer', ['length' => 11]) + ->addColumn('type', 'string', ['length' => 64]) + ->addColumn('alert', 'boolean', ['default' => false]) + ->addColumn('email', 'boolean', ['default' => false]); + + $table->addForeignKey('user_id', 'nl2_users', 'id', ['delete' => 'CASCADE']); + + $table->create(); + } +} diff --git a/core/migrations/20231121184810_add_content_rich_to_alerts_table.php b/core/migrations/20231121184810_add_content_rich_to_alerts_table.php new file mode 100644 index 0000000000..437c036bdf --- /dev/null +++ b/core/migrations/20231121184810_add_content_rich_to_alerts_table.php @@ -0,0 +1,16 @@ +table('nl2_alerts') + ->addColumn('content_rich', 'text', ['default' => null, 'limit' => MysqlAdapter::TEXT_MEDIUM, 'null' => true]) + ->update(); + } +} diff --git a/core/migrations/20240309204948_add_purify_bypass_colum_to_alerts_table.php b/core/migrations/20240309204948_add_purify_bypass_colum_to_alerts_table.php new file mode 100644 index 0000000000..0fcae5c714 --- /dev/null +++ b/core/migrations/20240309204948_add_purify_bypass_colum_to_alerts_table.php @@ -0,0 +1,15 @@ +table('nl2_alerts') + ->addColumn('bypass_purify', 'boolean', ['default' => false]) + ->update(); + } +} diff --git a/custom/panel_templates/Default/core/announcements.tpl b/custom/panel_templates/Default/core/announcements.tpl index a0ce68aa6a..cec036b01a 100644 --- a/custom/panel_templates/Default/core/announcements.tpl +++ b/custom/panel_templates/Default/core/announcements.tpl @@ -25,6 +25,7 @@

{$ANNOUNCEMENTS}

diff --git a/custom/panel_templates/Default/core/announcements_form.tpl b/custom/panel_templates/Default/core/announcements_form.tpl index 1a0c9d8ad4..203609be84 100644 --- a/custom/panel_templates/Default/core/announcements_form.tpl +++ b/custom/panel_templates/Default/core/announcements_form.tpl @@ -25,6 +25,7 @@

{$ANNOUNCEMENTS}

diff --git a/custom/panel_templates/Default/core/emails_mass_message.tpl b/custom/panel_templates/Default/core/emails_mass_message.tpl deleted file mode 100644 index caaa20b437..0000000000 --- a/custom/panel_templates/Default/core/emails_mass_message.tpl +++ /dev/null @@ -1,90 +0,0 @@ -{include file='header.tpl'} - - - - -
- - - {include file='sidebar.tpl'} - - -
- - -
- - - {include file='navbar.tpl'} - - -
- - -
-

{$EMAILS_MASS_MESSAGE}

- -
- - - {include file='includes/update.tpl'} - -
-
- {$BACK} -
- - - {include file='includes/alerts.tpl'} - -
-
- - -
-
- -
- -
-
-
- - - -
-
- -
-
- - -
- - -
- - -
- - {include file='footer.tpl'} - - -
- - -
- - {include file='scripts.tpl'} - - - - diff --git a/custom/panel_templates/Default/core/mass_message.tpl b/custom/panel_templates/Default/core/mass_message.tpl new file mode 100644 index 0000000000..ce9b6c3303 --- /dev/null +++ b/custom/panel_templates/Default/core/mass_message.tpl @@ -0,0 +1,174 @@ +{include file='header.tpl'} + + + + +
+ + + {include file='sidebar.tpl'} + + +
+ + +
+ + + {include file='navbar.tpl'} + + +
+ + +
+

{$EMAILS_MASS_MESSAGE}

+ +
+ + + {include file='includes/update.tpl'} + +
+
+ + {include file='includes/alerts.tpl'} + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + + +
+
+ + + +
+
+
+
+ {$EXCLUSION_INCLUSION_INFO} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ +
+
+ + +
+ + +
+ + +
+ + {include file='footer.tpl'} + + +
+ + +
+ + {include file='scripts.tpl'} + + + + + + diff --git a/custom/templates/DefaultRevamp/css/custom.css b/custom/templates/DefaultRevamp/css/custom.css index ae0b7bcf8c..042af44f64 100755 --- a/custom/templates/DefaultRevamp/css/custom.css +++ b/custom/templates/DefaultRevamp/css/custom.css @@ -1523,9 +1523,24 @@ body.dark ::-webkit-scrollbar-corner { } /* - * Colours - */ + * Colours + */ body.dark .white { color: #fff; } + +/* + * Accessiblity + */ + +.screenreader-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + border: 0; +} diff --git a/custom/templates/DefaultRevamp/user/alert.tpl b/custom/templates/DefaultRevamp/user/alert.tpl new file mode 100755 index 0000000000..ac9dbd9da2 --- /dev/null +++ b/custom/templates/DefaultRevamp/user/alert.tpl @@ -0,0 +1,47 @@ +{include file='header.tpl'} +{include file='navbar.tpl'} + +

+ {$TITLE} +

+ +
+
+
+ {include file='user/navigation.tpl'} +
+
+
+

+ {$ALERT_TITLE} + {if !$ALERT_READ} + + {$NEW} + + {/if} +
+ {if isset($VIEW)} + {$VIEW} + {/if} + {$BACK} +
+ + +
+
+

+ {if isset($ERROR)} +
{$ERROR}
+ {/if} + +
+ +
+ {$ALERT_CONTENT} +
+
+
+
+
+ +{include file='footer.tpl'} \ No newline at end of file diff --git a/custom/templates/DefaultRevamp/user/alerts.tpl b/custom/templates/DefaultRevamp/user/alerts.tpl index d173d8be24..320b97e2bd 100755 --- a/custom/templates/DefaultRevamp/user/alerts.tpl +++ b/custom/templates/DefaultRevamp/user/alerts.tpl @@ -29,24 +29,20 @@
{nocache} {if count($ALERTS_LIST)} - {foreach from=$ALERTS_LIST key=name item=alert} - + {foreach from=$ALERTS_LIST item=alert} +
- {if $alert->read eq 0} - {$alert->content} + {if $alert.read eq 0} + {$alert.title} {else} - {$alert->content} + {$alert.title} {/if} -
{$alert->date_nice} +
{$alert.date_nice}
- {/foreach} {else}
diff --git a/custom/templates/DefaultRevamp/user/notification_settings.tpl b/custom/templates/DefaultRevamp/user/notification_settings.tpl new file mode 100644 index 0000000000..776f0ddb78 --- /dev/null +++ b/custom/templates/DefaultRevamp/user/notification_settings.tpl @@ -0,0 +1,78 @@ +{include file='header.tpl'} +{include file='navbar.tpl'} + +

+ {$TITLE} +

+ +
+
+
+ {include file='user/navigation.tpl'} +
+
+
+

{$NOTIFICATION_SETTINGS_TITLE}

+ + {if isset($SUCCESS)} +
+ +
+
{$SUCCESS_TITLE}
+ {$SUCCESS} +
+
+ {/if} + + {if isset($ERRORS)} +
+ +
+
    + {foreach from=$ERRORS item=error} +
  • {$error}
  • + {/foreach} +
+
+
+ {/if} + +
+ + + + + + + + + + {foreach $NOTIFICATION_SETTINGS as $setting} + + + + + + {/foreach} + +
{$ALERT}{$EMAIL}
{$setting.value} +
+ + +
+
+
+ + +
+
+ + + +
+
+
+
+
+ +{include file='footer.tpl'} diff --git a/modules/Core/classes/Core/Notification.php b/modules/Core/classes/Core/Notification.php new file mode 100644 index 0000000000..2fd6ffedf6 --- /dev/null +++ b/modules/Core/classes/Core/Notification.php @@ -0,0 +1,128 @@ +_authorId = $authorId; + $this->_skipPurify = $skipPurify; + $this->_title = $title; + $this->_type = $type; + + if (!is_array($recipients)) { + $recipients = [$recipients]; + } + + $this->_recipients = array_map(static function ($recipient) use ($content, $contentCallback, $skipPurify, $title) { + $newContent = $contentCallback($recipient, $title, $content, $skipPurify); + return ['id' => $recipient, 'content' => $newContent]; + }, $recipients); + } + + public function send(): void { + /** @var array $recipient */ + foreach ($this->_recipients as $recipient) { + $id = $recipient['id']; + $content = $recipient['content']; + + $preferences = DB::getInstance()->query( + <<_type, $id] + )->first(); + + if ($preferences->alert) { + $this->sendAlert($id, $content); + } + if ($preferences->email) { + $this->sendEmail($id, $content); + } + } + } + + private function sendAlert(int $userId, string $content): void { + Alert::send($userId, $this->_title, $content, null, $this->_skipPurify); + } + + private function sendEmail(int $userId, string $content): void { + $task = (new SendEmail())->fromNew( + Module::getIdFromName('Core'), + 'Send Email Notification', + [ + 'content' => $content, + 'title' => $this->_title, + ], + date('U'), // TODO: schedule a date/time? + 'User', + $userId, + false, + null, + $this->_authorId + ); + + Queue::schedule($task); + } + + /** + * Register a custom notification type + * @param string $type + * @param string $value Human readable + * @param int $moduleId + * @return void + */ + public static function addType(string $type, string $value, int $moduleId): void { + self::$_types[] = ['key' => $type, 'value' => $value, 'module' => $moduleId]; + } + + /** + * Returns all registered notification types + * @return array + */ + public static function getTypes(): array { + return self::$_types; + } +} diff --git a/modules/Core/classes/Events/GenerateNotificationContentEvent.php b/modules/Core/classes/Events/GenerateNotificationContentEvent.php new file mode 100644 index 0000000000..8d0f08a955 --- /dev/null +++ b/modules/Core/classes/Events/GenerateNotificationContentEvent.php @@ -0,0 +1,32 @@ +content = $content; + $this->skip_purify = $skip_purify; + $this->title = $title; + $this->user = $user; + } + + public static function name(): string { + return 'generateNotificationContent'; + } + + public static function description(): string { + return (new Language())->get('admin', 'generate_notification_content_hook_info'); + } + + public static function internal(): bool { + return true; + } + + public static function return(): bool { + return true; + } +} diff --git a/modules/Core/classes/Exceptions/NotificationTypeNotFoundException.php b/modules/Core/classes/Exceptions/NotificationTypeNotFoundException.php new file mode 100644 index 0000000000..eb62169147 --- /dev/null +++ b/modules/Core/classes/Exceptions/NotificationTypeNotFoundException.php @@ -0,0 +1,3 @@ +getFragmentNext() ?? 0; + $end = $start + $limit; + $nextStatus = Task::STATUS_READY; + + if ($end > $this->getFragmentTotal()) { + $end = $this->getFragmentTotal(); + $nextStatus = Task::STATUS_COMPLETED; + } + + $where = ''; + $whereVars = []; + if (!empty($this->getData()['users'])) { + $whereIn = implode(',', array_map(static fn ($u) => '?', $this->getData()['users'])); + $where = "WHERE id IN ($whereIn)"; + $whereVars = array_map(static fn ($u) => $u['id'], $this->getData()['users']); + } + + $recipients = DB::getInstance()->query( + <<getData()['type'], + $this->getData()['title'], + $this->getData()['content'], + array_map(static fn ($r) => $r->id, $recipients->results()), + $this->getUserId(), + $this->getData()['callback'], + $this->getData()['skip_purify'] ?? false + ); + $notification->send(); + + $this->setOutput(['userIds' => $whereVars, 'start' => $start, 'end' => $end, 'next_status' => $nextStatus]); + $this->setFragmentNext($end); + + return $nextStatus; + } + + public static function parseContent(int $userId, string $title, string $content, bool $skipPurify = false): string { + $user = new User($userId); + $event = EventHandler::executeEvent(new GenerateNotificationContentEvent($content, $title, $user, $skipPurify)); + + return $event['content']; + } +} diff --git a/modules/Core/classes/Tasks/SendEmail.php b/modules/Core/classes/Tasks/SendEmail.php new file mode 100644 index 0000000000..a2ec4e6ecb --- /dev/null +++ b/modules/Core/classes/Tasks/SendEmail.php @@ -0,0 +1,78 @@ +_container->get(Language::class); + + if (!$this->getEntityId()) { + $this->setOutput([ + 'errors' => [$language->get('admin', 'email_task_error')], + 'data' => ['field' => 'entityId'], + ]); + return Task::STATUS_ERROR; + } + + $user = new User($this->getEntityId()); + + if (!$user->exists()) { + $this->setOutput([ + 'errors' => [$language->get('admin', 'email_task_error')], + 'data' => ['field' => 'entityId'], + ]); + return Task::STATUS_ERROR; + } + + $validate = Validate::check( + $this->getData(), + [ + 'title' => [ + Validate::REQUIRED => true, + Validate::MIN => 1, + ], + 'content' => [ + Validate::REQUIRED => true, + Validate::MIN => 1, + Validate::MAX => EMAIL_MAX_LENGTH, + ], + ], + ); + + if (!$validate->passed()) { + $this->setOutput([ + 'errors' => [$language->get('admin', 'email_task_error')], + 'data' => $validate->errors(), + ]); + return Task::STATUS_ERROR; + } + + $username = $user->getDisplayname(); + $title = Output::getPurified($this->getData()['title']); + + $content = $this->getData()['content']; + + $sent = Email::send( + ['email' => $user->data()->email, 'name' => $username], + $title, + $content, + ); + + if (isset($sent['error'])) { + DB::getInstance()->insert('email_errors', [ + 'type' => Email::MASS_MESSAGE, + 'content' => $sent['error'], + 'at' => date('U'), + 'user_id' => $this->getEntityId(), + ]); + + $this->setOutput([ + 'errors' => [$language->get('admin', 'email_task_error')], + 'data' => $sent['error'], + ]); + + return Task::STATUS_ERROR; + } + + return Task::STATUS_COMPLETED; + } +} diff --git a/modules/Core/hooks/GenerateNotificationContentHook.php b/modules/Core/hooks/GenerateNotificationContentHook.php new file mode 100644 index 0000000000..93ffea39a7 --- /dev/null +++ b/modules/Core/hooks/GenerateNotificationContentHook.php @@ -0,0 +1,23 @@ +user->getDisplayname(), + SITE_NAME, + ], + $event->content + ); + + $event->content = $content; + + return $event; + } +} diff --git a/modules/Core/includes/constants/constants.php b/modules/Core/includes/constants/constants.php new file mode 100644 index 0000000000..e251f6b201 --- /dev/null +++ b/modules/Core/includes/constants/constants.php @@ -0,0 +1,3 @@ +• Native {{nativeExample}}
• Twemoji {{twemojiExample}}
• JoyPixels {{joypixelsExample}}", "admin/emoji_native": "Native", @@ -254,6 +253,7 @@ "admin/forum_posts": "Display on Forum", "admin/forum_topic_reply_email": "Forum Topic Reply", "admin/general_settings": "General Settings", + "admin/generate_notification_content_hook_info": "Generates notification content before sending", "admin/generate_sitemap": "Generate Sitemap", "admin/google_analytics": "Google Analytics", "admin/google_analytics_help": "Add Google Analytics to your website to track visitors and statistics. You will need to create a Google Analytics account to use this functionality. Enter your Google Analytics Web Property ID. The ID looks like UA-XXXXA-X and you can find it in your account information or in the tracking code provided by Google.", @@ -353,7 +353,22 @@ "admin/maintenance_mode_message": "Maintenance mode message", "admin/make_default": "Make Default", "admin/manual_linking": "Manual Linking", - "admin/mass_email_failed_check_logs": "One or more emails failed to send. Please check the email logs for more information.", + "admin/mass_message": "Mass Message", + "admin/mass_message_content_maximum": "The message content must be at most {{max}} characters", + "admin/mass_message_content_required": "Please enter message content", + "admin/mass_message_excluded_groups": "Excluded Groups", + "admin/mass_message_excluded_users": "Excluded Users", + "admin/mass_message_exclusions_override_inclusions": "Excluded groups/users override included groups/users", + "admin/mass_message_ignore_opt_in": "Ignore email opt-in?", + "admin/mass_message_ignore_opt_in_info": "This will send to all users, regardless of their preference regarding receiving emails", + "admin/mass_message_included_groups": "Included Groups", + "admin/mass_message_included_users": "Included Users", + "admin/mass_message_replacements": "You can use variables in your email message. Supported variables: {username}, {sitename}", + "admin/mass_message_subject_required": "Please enter a subject", + "admin/mass_message_type": "Message Type", + "admin/mass_message_type_alert": "Alert", + "admin/mass_message_type_email": "Email", + "admin/mass_message_type_required": "Please select a message type", "admin/message": "Message", "admin/message_required": "Message is required", "admin/metadata_updated_successfully": "Metadata updated successfully.", @@ -696,7 +711,7 @@ "admin/unlink_account_confirm": "Are you sure you want to forcibly unlink this provider from this user?", "admin/unlink_account_success": "Successfully unlinked their account from {{provider}}.", "admin/unsafe_html": "Allow unsafe HTML?", - "admin/unsafe_html_warning": "Enabling this option means any HTML can be used on the page, including potentially dangerous JavaScript. Only enable this if you are sure your HTML is safe.", + "admin/unsafe_html_warning": "Enabling this option means any HTML can be used, including potentially dangerous JavaScript. Only enable this if you are sure your HTML is safe.", "admin/up_to_date": "Your NamelessMC installation is up to date!", "admin/update": "Update", "admin/updated": "Updated", @@ -845,6 +860,7 @@ "general/log_out_click": "Click here to log out", "general/log_out_complete": "Logout successful. Click {{linkStart}}here{{linkEnd}} to continue.", "general/more": "More", + "general/new": "New", "general/next": "Next", "general/no": "No", "general/no_default_server": "There is no default server, please select one in the StaffCP -> Integrations -> Minecraft tab.", @@ -1093,6 +1109,7 @@ "moderator/when": "When", "moderator/x_closed_report": "{{user}} closed this report.", "moderator/x_reopened_report": "{{user}} reopened this report.", + "notification/mass_message": "Mass Message", "table/display_records_per_page": "Display _MENU_ records per page", "table/filtered": "(filtered from _MAX_ total records)", "table/no_records": "No records available", @@ -1121,6 +1138,7 @@ "user/active_template": "Active Template", "user/agree_t_and_c": "I have read and accept the {{linkStart}}Terms and Conditions{{linkEnd}}.", "user/alerts": "Alerts", + "user/alerts_follow_link": "Follow alert link", "user/authme_account_linked": "Account linked successfully.", "user/authme_account_not_found": "That AuthMe account could not be found.", "user/authme_email_help_1": "Finally, please enter the following details.", @@ -1236,6 +1254,8 @@ "user/no_user_found_with_provider": "No user found with that {{provider}} account.", "user/no_wall_posts": "There are no wall posts here yet.", "user/not_connected": "Not Connected", + "user/notification_settings": "Notification Settings", + "user/notification_settings_updated_successfully": "Notification settings updated successfully.", "user/overview": "Overview", "user/oauth_already_linked": "Another NamelessMC user is already linked to that {{provider}} account.", "user/oauth_login_success": "You have logged in with your {{provider}} account.", diff --git a/modules/Core/module.php b/modules/Core/module.php index c54030ebd9..70b264c910 100644 --- a/modules/Core/module.php +++ b/modules/Core/module.php @@ -67,6 +67,7 @@ public function __construct(Language $language, Pages $pages, User $user, Naviga $pages->add('Core', '/user/settings', 'pages/user/settings.php'); $pages->add('Core', '/user/messaging', 'pages/user/messaging.php'); $pages->add('Core', '/user/alerts', 'pages/user/alerts.php'); + $pages->add('Core', '/user/notification_settings', 'pages/user/notification_settings.php'); $pages->add('Core', '/user/placeholders', 'pages/user/placeholders.php'); $pages->add('Core', '/user/acknowledge', 'pages/user/acknowledge.php'); $pages->add('Core', '/user/connections', 'pages/user/connections.php'); @@ -83,7 +84,7 @@ public function __construct(Language $language, Pages $pages, User $user, Naviga $pages->add('Core', '/panel/core/errors', 'pages/panel/errors.php'); $pages->add('Core', '/panel/core/emails', 'pages/panel/emails.php'); $pages->add('Core', '/panel/core/emails/errors', 'pages/panel/emails_errors.php'); - $pages->add('Core', '/panel/core/emails/mass_message', 'pages/panel/emails_mass_message.php'); + $pages->add('Core', '/panel/core/mass_message', 'pages/panel/mass_message.php'); $pages->add('Core', '/panel/core/navigation', 'pages/panel/navigation.php'); $pages->add('Core', '/panel/core/privacy_and_terms', 'pages/panel/privacy_and_terms.php'); $pages->add('Core', '/panel/core/reactions', 'pages/panel/reactions.php'); @@ -303,6 +304,7 @@ public function __construct(Language $language, Pages $pages, User $user, Naviga // -- Events EventHandler::registerEvent(AnnouncementCreatedEvent::class); + EventHandler::registerEvent(GenerateNotificationContentEvent::class); EventHandler::registerEvent(GroupClonedEvent::class); EventHandler::registerEvent(ReportCreatedEvent::class); EventHandler::registerEvent(UserBannedEvent::class); @@ -512,6 +514,10 @@ public function __construct(Language $language, Pages $pages, User $user, Naviga EventHandler::registerListener(GroupClonedEvent::class, CloneGroupHook::class); + EventHandler::registerListener(GenerateNotificationContentEvent::class, 'ContentHook::purify'); + EventHandler::registerListener(GenerateNotificationContentEvent::class, 'ContentHook::renderEmojis', 10); + EventHandler::registerListener(GenerateNotificationContentEvent::class, 'MentionsHook::parsePost', 5); + // TODO: Use [class, 'method'] callable syntax EventHandler::registerListener('renderPrivateMessage', 'ContentHook::purify'); EventHandler::registerListener('renderPrivateMessage', 'ContentHook::renderEmojis', 10); @@ -530,6 +536,7 @@ public function __construct(Language $language, Pages $pages, User $user, Naviga EventHandler::registerListener('renderCustomPageEdit', 'ContentHook::replaceAnchors', 15); + // TODO: ContentHook::decode is deprecated - do we need to decode profile posts saved in the DB using the queue?? EventHandler::registerListener('renderProfilePost', [ContentHook::class, 'decode'], 20); EventHandler::registerListener('renderProfilePost', [ContentHook::class, 'purify']); EventHandler::registerListener('renderProfilePost', [ContentHook::class, 'renderEmojis']); @@ -551,6 +558,9 @@ public function __construct(Language $language, Pages $pages, User $user, Naviga }); ReactionContextsManager::getInstance()->provideContext(new ProfilePostReactionContext()); + + // Notifications + Notification::addType('mass_message', $language->get('notification', 'mass_message'), Module::getIdFromName('Core')); } public static function getDashboardGraphs(): array { @@ -606,7 +616,7 @@ public function onPageLoad(User $user, Pages $pages, Cache $cache, Smarty $smart 'admincp.core.debugging' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'debugging_and_maintenance'), 'admincp.errors' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'debugging_and_maintenance') . ' » ' . $language->get('admin', 'error_logs'), 'admincp.core.emails' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'emails'), - 'admincp.core.emails_mass_message' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'emails_mass_message'), + 'admincp.core.emails_mass_message' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'mass_message'), 'admincp.core.navigation' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'navigation'), 'admincp.core.queue' => $language->get('admin', 'core') . ' » ' . $language->get('admin', 'queue'), 'admincp.core.reactions' => $language->get('admin', 'core') . ' » ' . $language->get('user', 'reactions'), @@ -1153,7 +1163,7 @@ public function onPageLoad(User $user, Pages $pages, Cache $cache, Smarty $smart } } - if ($user->hasPermission('admincp.core.announcements')) { + if ($user->hasPermission('admincp.core.announcements') || $user->hasPermission('admincp.core.emails_mass_message')) { if (!$cache->isCached('announcements_order')) { $order = 4; $cache->store('announcements_order', 4); @@ -1168,7 +1178,22 @@ public function onPageLoad(User $user, Pages $pages, Cache $cache, Smarty $smart $icon = $cache->retrieve('announcements_icon'); } - $navs[2]->add('announcements', $language->get('admin', 'announcements'), URL::build('/panel/core/announcements'), 'top', null, $order, $icon); + $navs[2]->addDropdown('announcements', $language->get('admin', 'communications'), 'top', $order, $icon); + + if ($user->hasPermission('admincp.core.announcements')) { + $navs[2]->addItemToDropdown('announcements', 'announcements', $language->get('admin', 'announcements'), URL::build('/panel/core/announcements'), 'top', null, $icon, 1); + } + + if ($user->hasPermission('admincp.core.emails_mass_message')) { + if (!$cache->isCached('mass_message_icon')) { + $icon = ''; + $cache->store('mass_message_icon', $icon); + } else { + $icon = $cache->retrieve('mass_message_icon'); + } + + $navs[2]->addItemToDropdown('announcements', 'mass_message', $language->get('admin', 'mass_message'), URL::build('/panel/core/mass_message'), 'top', null, $icon, 1); + } } if ($user->hasPermission('admincp.integrations')) { diff --git a/modules/Core/pages/panel/announcements.php b/modules/Core/pages/panel/announcements.php index 46706f9413..452898603d 100644 --- a/modules/Core/pages/panel/announcements.php +++ b/modules/Core/pages/panel/announcements.php @@ -303,7 +303,7 @@ 'PAGE' => PANEL_PAGE, 'PARENT_PAGE' => PARENT_PAGE, 'DASHBOARD' => $language->get('admin', 'dashboard'), - 'CONFIGURATION' => $language->get('admin', 'configuration'), + 'COMMUNICATIONS' => $language->get('admin', 'communications'), 'TOKEN' => Token::get(), 'SUBMIT' => $language->get('general', 'submit'), 'ARE_YOU_SURE' => $language->get('general', 'are_you_sure'), diff --git a/modules/Core/pages/panel/emails.php b/modules/Core/pages/panel/emails.php index 275b8dcc97..2cc8773945 100644 --- a/modules/Core/pages/panel/emails.php +++ b/modules/Core/pages/panel/emails.php @@ -187,14 +187,14 @@ if ($user->hasPermission('admincp.core.emails_mass_message')) { $smarty->assign([ - 'MASS_MESSAGE' => $language->get('admin', 'emails_mass_message'), - 'MASS_MESSAGE_LINK' => URL::build('/panel/core/emails/mass_message'), + 'MASS_MESSAGE' => $language->get('admin', 'mass_message'), + 'MASS_MESSAGE_LINK' => URL::build('/panel/core/mass_message'), ]); } $smarty->assign([ - 'MASS_MESSAGE' => $language->get('admin', 'emails_mass_message'), - 'MASS_MESSAGE_LINK' => URL::build('/panel/core/emails/mass_message'), + 'MASS_MESSAGE' => $language->get('admin', 'mass_message'), + 'MASS_MESSAGE_LINK' => URL::build('/panel/core/mass_message'), 'EDIT_EMAIL_MESSAGES' => $language->get('admin', 'edit_email_messages'), 'EDIT_EMAIL_MESSAGES_LINK' => URL::build('/panel/core/emails/', 'action=edit_messages'), 'SEND_TEST_EMAIL' => $language->get('admin', 'send_test_email'), diff --git a/modules/Core/pages/panel/emails_errors.php b/modules/Core/pages/panel/emails_errors.php index 21b3cf4883..292eac337d 100644 --- a/modules/Core/pages/panel/emails_errors.php +++ b/modules/Core/pages/panel/emails_errors.php @@ -67,7 +67,7 @@ $type = $language->get('admin', 'forum_topic_reply_email'); break; case Email::MASS_MESSAGE: - $type = $language->get('admin', 'emails_mass_message'); + $type = $language->get('admin', 'mass_message'); break; default: $type = $language->get('admin', 'unknown'); @@ -176,7 +176,7 @@ $type = $language->get('admin', 'forum_topic_reply_email'); break; case Email::MASS_MESSAGE: - $type = $language->get('admin', 'emails_mass_message'); + $type = $language->get('admin', 'mass_message'); break; default: $type = $language->get('admin', 'unknown'); diff --git a/modules/Core/pages/panel/emails_mass_message.php b/modules/Core/pages/panel/emails_mass_message.php deleted file mode 100644 index d1e29fac65..0000000000 --- a/modules/Core/pages/panel/emails_mass_message.php +++ /dev/null @@ -1,134 +0,0 @@ -handlePanelPageLoad('admincp.core.emails_mass_message')) { - require_once(ROOT_PATH . '/403.php'); - die(); -} - -const PAGE = 'panel'; -const PARENT_PAGE = 'core_configuration'; -const PANEL_PAGE = 'emails'; -$page_title = $language->get('admin', 'emails_mass_message'); -require_once(ROOT_PATH . '/core/templates/backend_init.php'); - -// Handle input -if (Input::exists()) { - $errors = []; - - if (Token::check()) { - $validate = Validate::check($_POST, [ - 'subject' => [ - Validate::REQUIRED => true, - Validate::MIN => 1, - ], - 'content' => [ - Validate::REQUIRED => true, - Validate::MIN => 1, - Validate::MAX => 75000 - ] - ]); - - if ($validate->passed()) { - - $users = DB::getInstance()->get('users', ['id', '<>', 0])->results(); - - foreach ($users as $email_user) { - $sent = Email::send( - ['email' => $email_user->email, 'name' => $email_user->username], - Input::get('subject'), - str_replace(['{username}', '{sitename}'], [$email_user->username, SITE_NAME], Output::getPurified(Input::get('content'))), - ); - - if (isset($sent['error'])) { - DB::getInstance()->insert('email_errors', [ - 'type' => Email::MASS_MESSAGE, - 'content' => $sent['error'], - 'at' => date('U'), - 'user_id' => $user->data()->id - ]); - - $errors[] = $language->get('admin', 'mass_email_failed_check_logs'); - } else { - Session::flash('emails_success', $language->get('admin', 'sent_mass_message')); - } - } - - Log::getInstance()->log(Log::Action('admin/core/email/mass_message')); - - } else { - $errors = $validate->errors(); - } - } else { - $errors[] = $language->get('general', 'invalid_token'); - } -} - -$php_mailer = Settings::get('phpmailer'); -$outgoing_email = Settings::get('outgoing_email'); - -$smarty->assign([ - 'SENDING_MASS_MESSAGE' => $language->get('admin', 'sending_mass_message'), - 'EMAILS_MASS_MESSAGE' => $language->get('admin', 'emails_mass_message'), - 'SUBJECT' => $language->get('admin', 'email_message_subject'), - 'CONTENT' => $language->get('general', 'content'), - 'INFO' => $language->get('general', 'info'), - 'REPLACEMENT_INFO' => $language->get('admin', 'emails_mass_message_replacements'), - 'LOADING' => $language->get('admin', 'emails_mass_message_loading'), - 'BACK' => $language->get('general', 'back'), - 'BACK_LINK' => URL::build('/panel/core/emails') -]); - -$template_file = 'core/emails_mass_message.tpl'; - -// Load modules + template -Module::loadPage($user, $pages, $cache, $smarty, [$navigation, $cc_nav, $staffcp_nav], $widgets, $template); - -$template->assets()->include([ - AssetTree::TINYMCE, -]); - -$template->addJSScript(Input::createTinyEditor($language, 'reply', null, false, true)); - -if (Session::exists('emails_success')) { - $success = Session::flash('emails_success'); -} - -if (isset($success)) { - $smarty->assign([ - 'SUCCESS' => $success, - 'SUCCESS_TITLE' => $language->get('general', 'success') - ]); -} - -if (isset($errors) && count($errors)) { - $smarty->assign([ - 'ERRORS' => $errors, - 'ERRORS_TITLE' => $language->get('general', 'error') - ]); -} - -$smarty->assign([ - 'PARENT_PAGE' => PARENT_PAGE, - 'DASHBOARD' => $language->get('admin', 'dashboard'), - 'CONFIGURATION' => $language->get('admin', 'configuration'), - 'EMAILS' => $language->get('admin', 'emails'), - 'PAGE' => PANEL_PAGE, - 'TOKEN' => Token::get(), - 'SUBMIT' => $language->get('general', 'submit') -]); - -$template->onPageLoad(); - -require(ROOT_PATH . '/core/templates/panel_navbar.php'); - -// Display template -$template->displayTemplate($template_file, $smarty); diff --git a/modules/Core/pages/panel/mass_message.php b/modules/Core/pages/panel/mass_message.php new file mode 100644 index 0000000000..067e485eff --- /dev/null +++ b/modules/Core/pages/panel/mass_message.php @@ -0,0 +1,256 @@ +handlePanelPageLoad('admincp.core.emails_mass_message')) { + require_once ROOT_PATH . '/403.php'; + die(); +} + +const PAGE = 'panel'; +const PARENT_PAGE = 'announcements'; +const PANEL_PAGE = 'mass_message'; +$page_title = $language->get('admin', 'mass_message'); +require_once ROOT_PATH . '/core/templates/backend_init.php'; + +// Handle input +if (Input::exists()) { + $errors = []; + + if (Token::check()) { + $validation = Validate::check($_POST, [ + 'subject' => [ + Validate::REQUIRED => true, + Validate::MIN => 1, + ], + 'content' => [ + Validate::REQUIRED => true, + Validate::MIN => 1, + Validate::MAX => EMAIL_MAX_LENGTH, + ], + ])->messages([ + 'subject' => [ + Validate::REQUIRED => $language->get('admin', 'mass_message_subject_required'), + Validate::MIN => $language->get('admin', 'mass_message_subject_required'), + ], + 'content' => [ + Validate::REQUIRED => $language->get('admin', 'mass_message_content_required'), + Validate::MIN => $language->get('admin', 'mass_message_content_required'), + Validate::MAX => $language->get('admin', 'mass_message_content_maximum', ['max' => EMAIL_MAX_LENGTH]), + ], + ]); + + if ($validation->passed()) { + // TODO: validation can't handle array passed in as value + if (!empty($_POST['type'])) { + // Exclusions + $excludedUsers = []; + if (isset($_POST['exclude_groups'])) { + $in = implode(',', array_map(static fn ($g) => '?', $_POST['exclude_groups'])); + $excludeUsers = DB::getInstance()->query( + <<results(); + $excludedUsers = array_map(static fn ($u) => $u->id, $excludeUsers); + } + if (isset($_POST['exclude_users'])) { + $excludedUsers = array_merge($excludedUsers, $_POST['exclude_users']); + } + + // Inclusions + $includedUsers = []; + if (isset($_POST['include_groups'])) { + $in = implode(',', array_map(static fn ($g) => '?', $_POST['include_groups'])); + $includeUsers = DB::getInstance()->query( + <<results(); + $includedUsers = array_map(static fn ($u) => $u->id, $includeUsers); + } + if (isset($_POST['include_users'])) { + $includedUsers = [...$includedUsers, ...$_POST['include_users']]; + } + + $join = ''; + $clause = ''; + if (!isset($_POST['ignore_opt_in']) || !$_POST['ignore_opt_in']) { + $join = 'INNER JOIN nl2_users_notification_preferences unp ON unp.user_id = u.id'; + $clause = 'unp.`type` = \'mass_message\' AND (unp.alert = 1 OR unp.email = 1)'; + } + + if (!empty($excludedUsers) || !empty($includedUsers)) { + $excludeClause = ''; + if (!empty($excludedUsers)) { + $excludedIn = implode(',', array_map(static fn ($u) => '?', $excludedUsers)); + $excludeClause = "u.ID NOT IN ($excludedIn)"; + } + + $includeClause = ''; + if (!empty($includedUsers)) { + $includedIn = implode(',', array_map(static fn ($u) => '?', $includedUsers)); + $includeClause = "u.ID IN ($includedIn)"; + } + + $glue = ''; + if ($excludeClause && $includeClause) { + $glue = 'AND'; + } + + $clause = "AND $clause"; + + $ids = array_merge($filterGroups ?? [], $filterUsers ?? []); + $users = DB::getInstance()->query( + <<query( + <<count(); + $users = $users->results(); + + $task = (new MassMessage())->fromNew( + Module::getIdFromName('Core'), + $language->get('admin', 'mass_message'), + [ + 'callback' => 'MassMessage::parseContent', + 'content' => Input::get('content'), + 'title' => Input::get('subject'), + 'type' => 'mass_message', + 'users' => $users, + 'skip_purify' => (bool) Input::get('unsafe_html'), + ], + date('U'), + null, + null, + true, + $total, + $user->data()->id + ); + + Queue::schedule($task); + + Log::getInstance()->log(Log::Action('admin/core/email/mass_message')); + + Session::flash('mass_message_success', $language->get('admin', 'sent_mass_message')); + Redirect::to(URL::build('/panel/core/mass_message')); + } else { + $errors = [$language->get('admin', 'mass_message_type_required')]; + } + } else { + $errors = $validation->errors(); + } + } else { + $errors[] = $language->get('general', 'invalid_token'); + } +} + +$allGroups = DB::getInstance()->query('SELECT id, name FROM nl2_groups')->results(); + +$smarty->assign([ + 'SENDING_MASS_MESSAGE' => $language->get('admin', 'sending_mass_message'), + 'COMMUNICATIONS' => $language->get('admin', 'communications'), + 'MASS_MESSAGE' => $language->get('admin', 'mass_message'), + 'SUBJECT' => $language->get('admin', 'email_message_subject'), + 'CONTENT' => $language->get('general', 'content'), + 'INFO' => $language->get('general', 'info'), + 'REPLACEMENT_INFO' => $language->get('admin', 'mass_message_replacements'), + 'ALL_GROUPS' => $allGroups, + 'USERS_QUERY_URL' => URL::build('/queries/users'), + 'NO_ITEM_SELECTED' => $language->get('admin', 'no_item_selected'), + 'EXCLUSION_INCLUSION_INFO' => $language->get('admin', 'mass_message_exclusions_override_inclusions'), + 'EXCLUDED_GROUPS' => $language->get('admin', 'mass_message_excluded_groups'), + 'EXCLUDED_USERS' => $language->get('admin', 'mass_message_excluded_users'), + 'IGNORE_OPT_IN' => $language->get('admin', 'mass_message_ignore_opt_in'), + 'IGNORE_OPT_IN_INFO' => $language->get('admin', 'mass_message_ignore_opt_in_info'), + 'INCLUDED_GROUPS' => $language->get('admin', 'mass_message_included_groups'), + 'INCLUDED_USERS' => $language->get('admin', 'mass_message_included_users'), + 'MESSAGE_TYPE' => $language->get('admin', 'mass_message_type'), + 'MESSAGE_TYPE_ALERT' => $language->get('admin', 'mass_message_type_alert'), + 'MESSAGE_TYPE_EMAIL' => $language->get('admin', 'mass_message_type_email'), + 'UNSAFE_HTML' => $language->get('admin', 'unsafe_html'), + 'UNSAFE_HTML_WARNING' => $language->get('admin', 'unsafe_html_warning'), +]); + +$template_file = 'core/mass_message.tpl'; + +// Load modules + template +Module::loadPage($user, $pages, $cache, $smarty, [$navigation, $cc_nav, $staffcp_nav], $widgets, $template); + +$template->assets()->include([ + AssetTree::TINYMCE, +]); + +$template->addJSScript(Input::createTinyEditor($language, 'message', null, true, true)); + +if (Session::exists('mass_message_success')) { + $success = Session::flash('mass_message_success'); +} + +if (isset($success)) { + $smarty->assign([ + 'SUCCESS' => $success, + 'SUCCESS_TITLE' => $language->get('general', 'success'), + ]); +} + +if (isset($errors) && count($errors)) { + $smarty->assign([ + 'ERRORS' => $errors, + 'ERRORS_TITLE' => $language->get('general', 'error'), + ]); +} + +$smarty->assign([ + 'PARENT_PAGE' => PARENT_PAGE, + 'DASHBOARD' => $language->get('admin', 'dashboard'), + 'CONFIGURATION' => $language->get('admin', 'configuration'), + 'EMAILS' => $language->get('admin', 'emails'), + 'PAGE' => PANEL_PAGE, + 'TOKEN' => Token::get(), + 'SUBMIT' => $language->get('general', 'submit'), +]); + +$template->onPageLoad(); + +require ROOT_PATH . '/core/templates/panel_navbar.php'; + +// Display template +$template->displayTemplate($template_file, $smarty); diff --git a/modules/Core/pages/user/alerts.php b/modules/Core/pages/user/alerts.php index 617263cbc6..438d533714 100644 --- a/modules/Core/pages/user/alerts.php +++ b/modules/Core/pages/user/alerts.php @@ -1,12 +1,23 @@ get('user', 'user_cp'); -require_once(ROOT_PATH . '/core/templates/frontend_init.php'); +$page_title = $language->get('user', 'alerts'); +require_once ROOT_PATH . '/core/templates/frontend_init.php'; -$timeago = new TimeAgo(TIMEZONE); +$timeAgo = new TimeAgo(TIMEZONE); if (!isset($_GET['view'])) { if (!isset($_GET['action'])) { // Get alerts - $alerts = DB::getInstance()->orderWhere('alerts', 'user_id = ' . $user->data()->id, 'created', 'DESC')->results(); - - $alerts_limited = []; - $n = 0; - - if (count($alerts) > 30) { - $limit = 30; - } else { - $limit = count($alerts); - } - - while ($n < $limit) { - // Only display 30 alerts - // Get date - $alerts[$n]->date = date(DATE_FORMAT, $alerts[$n]->created); - $alerts[$n]->date_nice = $timeago->inWords($alerts[$n]->created, $language); - $alerts[$n]->view_link = URL::build('/user/alerts/', 'view=' . urlencode($alerts[$n]->id)); - - $alerts_limited[] = $alerts[$n]; - - $n++; + $alerts = []; + $results = DB::getInstance()->query( + <<<'SQL' + SELECT + `id`, + `url`, + `content`, + `content_rich`, + `created`, + `read` + FROM nl2_alerts + WHERE `user_id` = ? + ORDER BY `created` DESC LIMIT 30 + SQL, + [$user->data()->id] + ); + + if ($results->count()) { + $results = $results->results(); + + $alerts = array_map(static fn ($alert) => [ + 'id' => $alert->id, + 'title' => Output::getClean($alert->content), + 'content_rich' => Output::getPurified($alert->content_rich), + 'date' => date(DATE_FORMAT, $alert->created), + 'date_nice' => $timeAgo->inWords($alert->created, $language), + 'view_link' => URL::build('/user/alerts/', 'view=' . $alert->id), + 'read' => $alert->read, + ], $results); } if (Session::exists('alerts_error')) { @@ -55,37 +74,33 @@ $smarty->assign([ 'USER_CP' => $language->get('user', 'user_cp'), 'ALERTS' => $language->get('user', 'alerts'), - 'ALERTS_LIST' => $alerts_limited, + 'ALERTS_LIST' => $alerts, 'DELETE_ALL' => $language->get('user', 'delete_all'), 'DELETE_ALL_LINK' => URL::build('/user/alerts/', 'action=purge'), - 'CLICK_TO_VIEW' => $language->get('user', 'click_here_to_view'), 'NO_ALERTS' => $language->get('user', 'no_alerts_usercp'), - 'TOKEN' => Token::get() ]); // Load modules + template Module::loadPage($user, $pages, $cache, $smarty, [$navigation, $cc_nav, $staffcp_nav], $widgets, $template); - require(ROOT_PATH . '/core/templates/cc_navbar.php'); + require ROOT_PATH . '/core/templates/cc_navbar.php'; $template->onPageLoad(); - require(ROOT_PATH . '/core/templates/navbar.php'); - require(ROOT_PATH . '/core/templates/footer.php'); + require ROOT_PATH . '/core/templates/navbar.php'; + require ROOT_PATH . '/core/templates/footer.php'; // Display template $template->displayTemplate('user/alerts.tpl', $smarty); - } else { - if ($_GET['action'] == 'purge') { - if (Token::check()) { - DB::getInstance()->delete('alerts', ['user_id', $user->data()->id]); - } else { - Session::flash('alerts_error', $language->get('general', 'invalid_token')); - } - - Redirect::to(URL::build('/user/alerts')); + } elseif ($_GET['action'] == 'purge') { + if (Token::check()) { + DB::getInstance()->delete('alerts', ['user_id', $user->data()->id]); + } else { + Session::flash('alerts_error', $language->get('general', 'invalid_token')); } + + Redirect::to(URL::build('/user/alerts')); } } else { @@ -94,18 +109,71 @@ Redirect::to(URL::build('/user/alerts')); } - // Check the alert belongs to the user.. - $alert = DB::getInstance()->get('alerts', ['id', $_GET['view']])->results(); + // Check the alert belongs to the user... + $alert = DB::getInstance()->get('alerts', ['id', $_GET['view']]); - if (!count($alert) || $alert[0]->user_id != $user->data()->id) { + if (!$alert->count() || $alert->first()->user_id !== $user->data()->id) { Redirect::to(URL::build('/user/alerts')); } - if ($alert[0]->read == 0) { - DB::getInstance()->update('alerts', $alert[0]->id, [ + $alert = $alert->first(); + + if (isset($_GET['delete'])) { + if (Token::check()) { + DB::getInstance()->delete('alerts', $alert->id); + Redirect::to('/user/alerts'); + } + + Session::flash('alerts_error', $language->get('general', 'invalid_token')); + Redirect::to(URL::build('/user/alerts', 'view=' . $alert->id)); + } + + if (!$alert->read) { + DB::getInstance()->update('alerts', $alert->id, [ 'read' => true, ]); } - Redirect::to($alert[0]->url != '#' ? $alert[0]->url : URL::build('/user/alerts')); + if (!$alert->content_rich) { + Redirect::to($alert->url && $alert->url !== '#' ? $alert->url : URL::build('/user/alerts')); + } + + if (Session::exists('alerts_error')) { + $smarty->assign('ERROR', Session::flash('alerts_error')); + } + + if ($alert->url && $alert->url !== '#') { + $smarty->assign([ + 'VIEW' => $language->get('user', 'alerts_follow_link'), + 'VIEW_LINK' => urlencode($alert->url), + ]); + } + + $smarty->assign([ + 'USER_CP' => $language->get('user', 'user_cp'), + 'ALERTS' => $language->get('user', 'alerts'), + 'DELETE' => $language->get('general', 'delete'), + 'DELETE_LINK' => URL::build('/user/alerts/', 'view=' . $alert->id . '&delete'), + 'ALERT_TITLE' => Output::getClean($alert->content), + 'ALERT_CONTENT' => $alert->bypass_purify ? $alert->content_rich : Output::getPurified($alert->content_rich), + 'ALERT_DATE' => date(DATE_FORMAT, $alert->created), + 'ALERT_DATE_NICE' => $timeAgo->inWords($alert->created, $language), + 'ALERT_READ' => $alert->read, + 'NEW' => $language->get('general', 'new'), + 'BACK' => $language->get('general', 'back'), + 'BACK_LINK' => URL::build('/user/alerts'), + ]); + + // Load modules + template + Module::loadPage($user, $pages, $cache, $smarty, [$navigation, $cc_nav, $staffcp_nav], $widgets, $template); + + require ROOT_PATH . '/core/templates/cc_navbar.php'; + + $template->onPageLoad(); + + require ROOT_PATH . '/core/templates/navbar.php'; + require ROOT_PATH . '/core/templates/footer.php'; + + // Display template + $template->displayTemplate('user/alert.tpl', $smarty); } diff --git a/modules/Core/pages/user/notification_settings.php b/modules/Core/pages/user/notification_settings.php new file mode 100644 index 0000000000..cc0f26643f --- /dev/null +++ b/modules/Core/pages/user/notification_settings.php @@ -0,0 +1,141 @@ +isLoggedIn()) { + Redirect::to(URL::build('/')); +} + +// Always define page name for navbar +const PAGE = 'cc_notification_settings'; +$page_title = $language->get('user', 'notification_settings'); +require_once ROOT_PATH . '/core/templates/frontend_init.php'; + +if (Input::exists()) { + if (Token::check()) { + $preferences = []; + + foreach (Notification::getTypes() as $type) { + foreach (['alert', 'email'] as $option) { + if ($_POST[$type['key'] . ':' . $option]) { + if (!isset($preferences[$type['key']])) { + $preferences[$type['key']] = []; + } + + $preferences[$type['key']][$option] = true; + } + } + } + + DB::getInstance()->delete('users_notification_preferences', ['user_id', $user->data()->id]); + + if (count($preferences)) { + $inserts = implode(', ', array_map(static fn () => '(?, ?, ?, ?)', $preferences)); + $values = []; + + foreach ($preferences as $key => $options) { + $values[] = $user->data()->id; + $values[] = $key; + $values[] = array_key_exists('alert', $options) ? 1 : 0; + $values[] = array_key_exists('email', $options) ? 1 : 0; + } + + DB::getInstance()->query( + <<get('user', 'notification_settings_updated_successfully')); + Redirect::to(URL::build('/user/notification_settings')); + + } else { + $errors = [$language->get('general', 'invalid_token')]; + } +} + +$preferences = DB::getInstance()->query( + 'SELECT `type`, `alert`, `email` FROM nl2_users_notification_preferences WHERE `user_id` = ?', + [$user->data()->id], +)->results(); + +$mappedPreferences = []; + +foreach (Notification::getTypes() as $type) { + $userTypePreference = array_search($type['key'], array_column($preferences, 'type')); + + $alert = $email = false; + if ($userTypePreference !== false) { + $alert = $preferences[$userTypePreference]->alert === 1; + $email = $preferences[$userTypePreference]->email === 1; + } + + $mappedPreferences[] = [ + 'type' => $type['key'], + 'value' => $type['value'], + 'alert' => $alert, + 'email' => $email, + ]; +} + +if (Session::exists('notification_settings_success')) { + $smarty->assign([ + 'SUCCESS' => Session::flash('notification_settings_success'), + 'SUCCESS_TITLE' => $language->get('general', 'success'), + ]); +} + +if (isset($errors)) { + $smarty->assign([ + 'ERRORS' => $errors, + ]); +} + +$smarty->assign([ + 'USER_CP' => $language->get('user', 'user_cp'), + 'NOTIFICATION_SETTINGS_TITLE' => $language->get('user', 'notification_settings'), + 'NOTIFICATION_SETTINGS' => $mappedPreferences, + 'OVERVIEW' => $language->get('user', 'overview'), + 'SUBMIT' => $language->get('general', 'submit'), + 'ALERT' => $language->get('admin', 'mass_message_type_alert'), + 'EMAIL' => $language->get('admin', 'mass_message_type_email'), + 'TOKEN' => Token::get(), +]); + +// Load modules + template +Module::loadPage($user, $pages, $cache, $smarty, [$navigation, $cc_nav, $staffcp_nav], $widgets, $template); + +require ROOT_PATH . '/core/templates/cc_navbar.php'; + +$template->onPageLoad(); + +require ROOT_PATH . '/core/templates/navbar.php'; +require ROOT_PATH . '/core/templates/footer.php'; + +// Display template +$template->displayTemplate('user/notification_settings.tpl', $smarty);