aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/updatenotification/appinfo/info.xml4
-rw-r--r--apps/updatenotification/appinfo/routes.php3
-rw-r--r--apps/updatenotification/composer/composer/autoload_classmap.php10
-rw-r--r--apps/updatenotification/composer/composer/autoload_static.php10
-rw-r--r--apps/updatenotification/lib/AppInfo/Application.php14
-rw-r--r--apps/updatenotification/lib/BackgroundJob/AppUpdatedNotifications.php124
-rw-r--r--apps/updatenotification/lib/Controller/APIController.php74
-rw-r--r--apps/updatenotification/lib/Controller/ChangelogController.php76
-rw-r--r--apps/updatenotification/lib/Listener/AppUpdateEventListener.php72
-rw-r--r--apps/updatenotification/lib/Listener/BeforeTemplateRenderedEventListener.php64
-rw-r--r--apps/updatenotification/lib/Manager.php131
-rw-r--r--apps/updatenotification/lib/Notification/AppUpdateNotifier.php128
-rw-r--r--apps/updatenotification/lib/Notification/Notifier.php66
-rw-r--r--apps/updatenotification/openapi.json140
-rw-r--r--apps/updatenotification/src/components/AppChangelogDialog.vue96
-rw-r--r--apps/updatenotification/src/components/Markdown.vue56
-rw-r--r--apps/updatenotification/src/composables/useMarkdown.ts62
-rw-r--r--apps/updatenotification/src/init.ts75
-rw-r--r--apps/updatenotification/src/updatenotification.js (renamed from apps/updatenotification/src/init.js)0
-rw-r--r--apps/updatenotification/src/view-changelog-page.ts8
-rw-r--r--apps/updatenotification/src/views/App.vue39
-rw-r--r--apps/updatenotification/templates/empty.php4
-rw-r--r--webpack.modules.js4
23 files changed, 1194 insertions, 66 deletions
diff --git a/apps/updatenotification/appinfo/info.xml b/apps/updatenotification/appinfo/info.xml
index 0df6ca8310f..dc150962786 100644
--- a/apps/updatenotification/appinfo/info.xml
+++ b/apps/updatenotification/appinfo/info.xml
@@ -3,8 +3,8 @@
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>updatenotification</id>
<name>Update notification</name>
- <summary>Displays update notifications for Nextcloud and provides the SSO for the updater.</summary>
- <description>Displays update notifications for Nextcloud and provides the SSO for the updater.</description>
+ <summary>Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.</summary>
+ <description>Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.</description>
<version>1.19.0</version>
<licence>agpl</licence>
<author>Lukas Reschke</author>
diff --git a/apps/updatenotification/appinfo/routes.php b/apps/updatenotification/appinfo/routes.php
index c6b823fd5ff..63f4e9c4971 100644
--- a/apps/updatenotification/appinfo/routes.php
+++ b/apps/updatenotification/appinfo/routes.php
@@ -27,8 +27,11 @@ return [
'routes' => [
['name' => 'Admin#createCredentials', 'url' => '/credentials', 'verb' => 'GET'],
['name' => 'Admin#setChannel', 'url' => '/channel', 'verb' => 'POST'],
+ // Fallback app changelog information for mobile clients
+ ['name' => 'Changelog#showChangelog', 'url' => '/changelog/{app}', 'verb' => 'GET'],
],
'ocs' => [
['name' => 'API#getAppList', 'url' => '/api/{apiVersion}/applist/{newVersion}', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v1)']],
+ ['name' => 'API#getAppChangelogEntry', 'url' => '/api/{apiVersion}/changelog/{appId}', 'verb' => 'GET', 'requirements' => ['apiVersion' => '(v1)']],
],
];
diff --git a/apps/updatenotification/composer/composer/autoload_classmap.php b/apps/updatenotification/composer/composer/autoload_classmap.php
index 61cfbe2ec76..a03003ef3b2 100644
--- a/apps/updatenotification/composer/composer/autoload_classmap.php
+++ b/apps/updatenotification/composer/composer/autoload_classmap.php
@@ -8,12 +8,18 @@ $baseDir = $vendorDir;
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'OCA\\UpdateNotification\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
+ 'OCA\\UpdateNotification\\BackgroundJob\\AppUpdatedNotifications' => $baseDir . '/../lib/BackgroundJob/AppUpdatedNotifications.php',
+ 'OCA\\UpdateNotification\\BackgroundJob\\ResetToken' => $baseDir . '/../lib/BackgroundJob/ResetToken.php',
+ 'OCA\\UpdateNotification\\BackgroundJob\\UpdateAvailableNotifications' => $baseDir . '/../lib/BackgroundJob/UpdateAvailableNotifications.php',
'OCA\\UpdateNotification\\Command\\Check' => $baseDir . '/../lib/Command/Check.php',
'OCA\\UpdateNotification\\Controller\\APIController' => $baseDir . '/../lib/Controller/APIController.php',
'OCA\\UpdateNotification\\Controller\\AdminController' => $baseDir . '/../lib/Controller/AdminController.php',
- 'OCA\\UpdateNotification\\Notification\\BackgroundJob' => $baseDir . '/../lib/Notification/BackgroundJob.php',
+ 'OCA\\UpdateNotification\\Controller\\ChangelogController' => $baseDir . '/../lib/Controller/ChangelogController.php',
+ 'OCA\\UpdateNotification\\Listener\\AppUpdateEventListener' => $baseDir . '/../lib/Listener/AppUpdateEventListener.php',
+ 'OCA\\UpdateNotification\\Listener\\BeforeTemplateRenderedEventListener' => $baseDir . '/../lib/Listener/BeforeTemplateRenderedEventListener.php',
+ 'OCA\\UpdateNotification\\Manager' => $baseDir . '/../lib/Manager.php',
+ 'OCA\\UpdateNotification\\Notification\\AppUpdateNotifier' => $baseDir . '/../lib/Notification/AppUpdateNotifier.php',
'OCA\\UpdateNotification\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php',
- 'OCA\\UpdateNotification\\ResetTokenBackgroundJob' => $baseDir . '/../lib/ResetTokenBackgroundJob.php',
'OCA\\UpdateNotification\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\UpdateNotification\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php',
'OCA\\UpdateNotification\\UpdateChecker' => $baseDir . '/../lib/UpdateChecker.php',
diff --git a/apps/updatenotification/composer/composer/autoload_static.php b/apps/updatenotification/composer/composer/autoload_static.php
index d83927001ad..57eedf5e075 100644
--- a/apps/updatenotification/composer/composer/autoload_static.php
+++ b/apps/updatenotification/composer/composer/autoload_static.php
@@ -23,12 +23,18 @@ class ComposerStaticInitUpdateNotification
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'OCA\\UpdateNotification\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
+ 'OCA\\UpdateNotification\\BackgroundJob\\AppUpdatedNotifications' => __DIR__ . '/..' . '/../lib/BackgroundJob/AppUpdatedNotifications.php',
+ 'OCA\\UpdateNotification\\BackgroundJob\\ResetToken' => __DIR__ . '/..' . '/../lib/BackgroundJob/ResetToken.php',
+ 'OCA\\UpdateNotification\\BackgroundJob\\UpdateAvailableNotifications' => __DIR__ . '/..' . '/../lib/BackgroundJob/UpdateAvailableNotifications.php',
'OCA\\UpdateNotification\\Command\\Check' => __DIR__ . '/..' . '/../lib/Command/Check.php',
'OCA\\UpdateNotification\\Controller\\APIController' => __DIR__ . '/..' . '/../lib/Controller/APIController.php',
'OCA\\UpdateNotification\\Controller\\AdminController' => __DIR__ . '/..' . '/../lib/Controller/AdminController.php',
- 'OCA\\UpdateNotification\\Notification\\BackgroundJob' => __DIR__ . '/..' . '/../lib/Notification/BackgroundJob.php',
+ 'OCA\\UpdateNotification\\Controller\\ChangelogController' => __DIR__ . '/..' . '/../lib/Controller/ChangelogController.php',
+ 'OCA\\UpdateNotification\\Listener\\AppUpdateEventListener' => __DIR__ . '/..' . '/../lib/Listener/AppUpdateEventListener.php',
+ 'OCA\\UpdateNotification\\Listener\\BeforeTemplateRenderedEventListener' => __DIR__ . '/..' . '/../lib/Listener/BeforeTemplateRenderedEventListener.php',
+ 'OCA\\UpdateNotification\\Manager' => __DIR__ . '/..' . '/../lib/Manager.php',
+ 'OCA\\UpdateNotification\\Notification\\AppUpdateNotifier' => __DIR__ . '/..' . '/../lib/Notification/AppUpdateNotifier.php',
'OCA\\UpdateNotification\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php',
- 'OCA\\UpdateNotification\\ResetTokenBackgroundJob' => __DIR__ . '/..' . '/../lib/ResetTokenBackgroundJob.php',
'OCA\\UpdateNotification\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
'OCA\\UpdateNotification\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php',
'OCA\\UpdateNotification\\UpdateChecker' => __DIR__ . '/..' . '/../lib/UpdateChecker.php',
diff --git a/apps/updatenotification/lib/AppInfo/Application.php b/apps/updatenotification/lib/AppInfo/Application.php
index 1aaf3be6e7c..b82355cf468 100644
--- a/apps/updatenotification/lib/AppInfo/Application.php
+++ b/apps/updatenotification/lib/AppInfo/Application.php
@@ -10,6 +10,7 @@ declare(strict_types=1);
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Roeland Jago Douma <roeland@famdouma.nl>
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license GNU AGPL version 3 or any later version
*
@@ -29,13 +30,18 @@ declare(strict_types=1);
*/
namespace OCA\UpdateNotification\AppInfo;
+use OCA\UpdateNotification\Listener\AppUpdateEventListener;
+use OCA\UpdateNotification\Listener\BeforeTemplateRenderedEventListener;
+use OCA\UpdateNotification\Notification\AppUpdateNotifier;
use OCA\UpdateNotification\Notification\Notifier;
use OCA\UpdateNotification\UpdateChecker;
+use OCP\App\Events\AppUpdateEvent;
use OCP\App\IAppManager;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IUser;
@@ -46,12 +52,18 @@ use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
class Application extends App implements IBootstrap {
+ public const APP_NAME = 'updatenotification';
+
public function __construct() {
- parent::__construct('updatenotification', []);
+ parent::__construct(self::APP_NAME, []);
}
public function register(IRegistrationContext $context): void {
$context->registerNotifierService(Notifier::class);
+ $context->registerNotifierService(AppUpdateNotifier::class);
+
+ $context->registerEventListener(AppUpdateEvent::class, AppUpdateEventListener::class);
+ $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedEventListener::class);
}
public function boot(IBootContext $context): void {
diff --git a/apps/updatenotification/lib/BackgroundJob/AppUpdatedNotifications.php b/apps/updatenotification/lib/BackgroundJob/AppUpdatedNotifications.php
new file mode 100644
index 00000000000..caeb5a8dfa9
--- /dev/null
+++ b/apps/updatenotification/lib/BackgroundJob/AppUpdatedNotifications.php
@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\UpdateNotification\BackgroundJob;
+
+use OCA\UpdateNotification\AppInfo\Application;
+use OCA\UpdateNotification\Manager;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\QueuedJob;
+use OCP\IConfig;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\Notification\IManager;
+use OCP\Notification\INotification;
+use Psr\Log\LoggerInterface;
+
+class AppUpdatedNotifications extends QueuedJob {
+ public function __construct(
+ ITimeFactory $time,
+ private IConfig $config,
+ private IManager $notificationManager,
+ private IUserManager $userManager,
+ private IAppManager $appManager,
+ private LoggerInterface $logger,
+ private Manager $manager,
+ ) {
+ parent::__construct($time);
+ }
+
+ /**
+ * @param array{appId: string, timestamp: int} $argument
+ */
+ protected function run(mixed $argument): void {
+ $appId = $argument['appId'];
+ $timestamp = $argument['timestamp'];
+ $dateTime = $this->time->getDateTime();
+ $dateTime->setTimestamp($timestamp);
+
+ $this->logger->debug(
+ 'Running background job to create app update notifications for "' . $appId . '"',
+ [
+ 'app' => Application::APP_NAME,
+ ],
+ );
+
+ if ($this->manager->getChangelogFile($appId, 'en') === null) {
+ $this->logger->debug('Skipping app updated notification - no changelog provided');
+ return;
+ }
+
+ $this->stopPreviousNotifications($appId);
+
+ // Create new notifications
+ $notification = $this->notificationManager->createNotification();
+ $notification->setApp(Application::APP_NAME)
+ ->setDateTime($dateTime)
+ ->setSubject('app_updated', [$appId])
+ ->setObject('app_updated', $appId);
+
+ $this->notifyUsers($appId, $notification);
+ }
+
+ /**
+ * Stop all previous notifications users might not have dismissed until now
+ * @param string $appId The app to stop update notifications for
+ */
+ private function stopPreviousNotifications(string $appId): void {
+ $notification = $this->notificationManager->createNotification();
+ $notification->setApp(Application::APP_NAME)
+ ->setObject('app_updated', $appId);
+ $this->notificationManager->markProcessed($notification);
+ }
+
+ /**
+ * Notify all users for which the updated app is enabled
+ */
+ private function notifyUsers(string $appId, INotification $notification): void {
+ $guestsEnabled = class_exists('\OCA\Guests\UserBackend');
+
+ $isDefer = $this->notificationManager->defer();
+
+ // Notify all seen users about the app update
+ $this->userManager->callForSeenUsers(function (IUser $user) use ($guestsEnabled, $appId, $notification) {
+ if ($guestsEnabled && ($user->getBackend() instanceof ('\OCA\Guests\UserBackend'))) {
+ return;
+ }
+
+ if (!$this->appManager->isEnabledForUser($appId, $user)) {
+ return;
+ }
+
+ $notification->setUser($user->getUID());
+ $this->notificationManager->notify($notification);
+ });
+
+ // If we enabled the defer we call the flush
+ if ($isDefer) {
+ $this->notificationManager->flush();
+ }
+ }
+}
diff --git a/apps/updatenotification/lib/Controller/APIController.php b/apps/updatenotification/lib/Controller/APIController.php
index d684b69024f..e2dd3684443 100644
--- a/apps/updatenotification/lib/Controller/APIController.php
+++ b/apps/updatenotification/lib/Controller/APIController.php
@@ -7,6 +7,7 @@ declare(strict_types=1);
*
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author Joas Schilling <coding@schilljs.com>
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license GNU AGPL version 3 or any later version
*
@@ -27,6 +28,7 @@ declare(strict_types=1);
namespace OCA\UpdateNotification\Controller;
use OC\App\AppStore\Fetcher\AppFetcher;
+use OCA\UpdateNotification\Manager;
use OCA\UpdateNotification\ResponseDefinitions;
use OCP\App\AppPathNotFoundException;
use OCP\App\IAppManager;
@@ -43,21 +45,6 @@ use OCP\L10N\IFactory;
*/
class APIController extends OCSController {
- /** @var IConfig */
- protected $config;
-
- /** @var IAppManager */
- protected $appManager;
-
- /** @var AppFetcher */
- protected $appFetcher;
-
- /** @var IFactory */
- protected $l10nFactory;
-
- /** @var IUserSession */
- protected $userSession;
-
/** @var string */
protected $language;
@@ -73,20 +60,17 @@ class APIController extends OCSController {
'twofactor_totp' => 25,
];
- public function __construct(string $appName,
+ public function __construct(
+ string $appName,
IRequest $request,
- IConfig $config,
- IAppManager $appManager,
- AppFetcher $appFetcher,
- IFactory $l10nFactory,
- IUserSession $userSession) {
+ protected IConfig $config,
+ protected IAppManager $appManager,
+ protected AppFetcher $appFetcher,
+ protected IFactory $l10nFactory,
+ protected IUserSession $userSession,
+ protected Manager $manager,
+ ) {
parent::__construct($appName, $request);
-
- $this->config = $config;
- $this->appManager = $appManager;
- $this->appFetcher = $appFetcher;
- $this->l10nFactory = $l10nFactory;
- $this->userSession = $userSession;
}
/**
@@ -178,4 +162,40 @@ class APIController extends OCSController {
'appName' => $name ?? $appId,
];
}
+
+ /**
+ * Get changelog entry for an app
+ *
+ * @param string $appId App to search changelog entry for
+ * @param string|null $version The version to search the changelog entry for (defaults to the latest installed)
+ *
+ * @return DataResponse<Http::STATUS_OK, array{appName: string, content: string, version: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{}, array{}>
+ *
+ * 200: Changelog entry returned
+ * 404: No changelog found
+ */
+ public function getAppChangelogEntry(string $appId, ?string $version = null): DataResponse {
+ $version = $version ?? $this->appManager->getAppVersion($appId);
+ $changes = $this->manager->getChangelog($appId, $version);
+
+ if ($changes === null) {
+ return new DataResponse([], Http::STATUS_NOT_FOUND);
+ }
+
+ // Remove version headline
+ /** @var string[] */
+ $changes = explode("\n", $changes, 2);
+ $changes = trim(end($changes));
+
+ // Get app info for localized app name
+ $info = $this->appManager->getAppInfo($appId) ?? [];
+ /** @var string */
+ $appName = $info['name'] ?? $appId;
+
+ return new DataResponse([
+ 'appName' => $appName,
+ 'content' => $changes,
+ 'version' => $version,
+ ]);
+ }
}
diff --git a/apps/updatenotification/lib/Controller/ChangelogController.php b/apps/updatenotification/lib/Controller/ChangelogController.php
new file mode 100644
index 00000000000..b9ac61353fa
--- /dev/null
+++ b/apps/updatenotification/lib/Controller/ChangelogController.php
@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+namespace OCA\UpdateNotification\Controller;
+
+use OCA\UpdateNotification\Manager;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http\Attribute\OpenAPI;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\IRequest;
+
+#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
+class ChangelogController extends Controller {
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private Manager $manager,
+ private IAppManager $appManager,
+ private IInitialState $initialState,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * This page is only used for clients not support showing the app changelog feature in-app and thus need to show it on a dedicated page.
+ * @param string $app App to show the changelog for
+ * @param string|null $version Version entry to show (defaults to latest installed)
+ * @NoCSRFRequired
+ * @NoAdminRequired
+ */
+ public function showChangelog(string $app, ?string $version = null): TemplateResponse {
+ $version = $version ?? $this->appManager->getAppVersion($app);
+ $appInfo = $this->appManager->getAppInfo($app) ?? [];
+ $appName = $appInfo['name'] ?? $app;
+
+ $changes = $this->manager->getChangelog($app, $version) ?? '';
+ // Remove version headline
+ /** @var string[] */
+ $changes = explode("\n", $changes, 2);
+ $changes = trim(end($changes));
+
+ $this->initialState->provideInitialState('changelog', [
+ 'appName' => $appName,
+ 'appVersion' => $version,
+ 'text' => $changes,
+ ]);
+
+ \OCP\Util::addScript($this->appName, 'view-changelog-page');
+ return new TemplateResponse($this->appName, 'empty');
+ }
+}
diff --git a/apps/updatenotification/lib/Listener/AppUpdateEventListener.php b/apps/updatenotification/lib/Listener/AppUpdateEventListener.php
new file mode 100644
index 00000000000..15b7223e0d4
--- /dev/null
+++ b/apps/updatenotification/lib/Listener/AppUpdateEventListener.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\UpdateNotification\Listener;
+
+use OCA\UpdateNotification\AppInfo\Application;
+use OCA\UpdateNotification\BackgroundJob\AppUpdatedNotifications;
+use OCP\App\Events\AppUpdateEvent;
+use OCP\BackgroundJob\IJobList;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+
+/** @template-implements IEventListener<AppUpdateEvent> */
+class AppUpdateEventListener implements IEventListener {
+
+ public function __construct(
+ private IJobList $jobList,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * @param AppUpdateEvent $event
+ */
+ public function handle(Event $event): void {
+ if (!($event instanceof AppUpdateEvent)) {
+ return;
+ }
+
+ foreach ($this->jobList->getJobsIterator(AppUpdatedNotifications::class, null, 0) as $job) {
+ // Remove waiting notification jobs for this app
+ if ($job->getArgument()['appId'] === $event->getAppId()) {
+ $this->jobList->remove($job);
+ }
+ }
+
+ $this->jobList->add(AppUpdatedNotifications::class, [
+ 'appId' => $event->getAppId(),
+ 'timestamp' => time(),
+ ]);
+
+ $this->logger->debug(
+ 'Scheduled app update notification for "' . $event->getAppId() . '"',
+ [
+ 'app' => Application::APP_NAME,
+ ],
+ );
+ }
+}
diff --git a/apps/updatenotification/lib/Listener/BeforeTemplateRenderedEventListener.php b/apps/updatenotification/lib/Listener/BeforeTemplateRenderedEventListener.php
new file mode 100644
index 00000000000..0f86337e969
--- /dev/null
+++ b/apps/updatenotification/lib/Listener/BeforeTemplateRenderedEventListener.php
@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\UpdateNotification\Listener;
+
+use OCA\UpdateNotification\AppInfo\Application;
+use OCP\App\IAppManager;
+use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use Psr\Log\LoggerInterface;
+
+/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
+class BeforeTemplateRenderedEventListener implements IEventListener {
+
+ public function __construct(
+ private IAppManager $appManager,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * @param BeforeTemplateRenderedEvent $event
+ */
+ public function handle(Event $event): void {
+ if (!($event instanceof BeforeTemplateRenderedEvent)) {
+ return;
+ }
+
+ // Only handle logged in users
+ if (!$event->isLoggedIn()) {
+ return;
+ }
+
+ // Ignore when notifications are disabled
+ if (!$this->appManager->isEnabledForUser('notifications')) {
+ return;
+ }
+
+ \OCP\Util::addInitScript(Application::APP_NAME, 'init');
+ }
+}
diff --git a/apps/updatenotification/lib/Manager.php b/apps/updatenotification/lib/Manager.php
new file mode 100644
index 00000000000..c8038a0b995
--- /dev/null
+++ b/apps/updatenotification/lib/Manager.php
@@ -0,0 +1,131 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\UpdateNotification;
+
+use OCP\App\IAppManager;
+use OCP\IUser;
+use OCP\IUserSession;
+use OCP\L10N\IFactory;
+use Psr\Log\LoggerInterface;
+
+class Manager {
+
+ private ?IUser $currentUser;
+
+ public function __construct(
+ IUserSession $currentSession,
+ private IAppManager $appManager,
+ private IFactory $l10NFactory,
+ private LoggerInterface $logger,
+ ) {
+ $this->currentUser = $currentSession->getUser();
+ }
+
+ /**
+ * Get the changelog entry for the given appId
+ * @param string $appId The app for which to query the entry
+ * @param string $version The version for which to query the changelog entry
+ * @param ?string $languageCode The language in which to query the changelog (defaults to current user language and fallsback to English)
+ * @return string|null Either the changelog entry or null if no changelog is found
+ */
+ public function getChangelog(string $appId, string $version, ?string $languageCode = null): string|null {
+ if ($languageCode === null) {
+ $languageCode = $this->l10NFactory->getUserLanguage($this->currentUser);
+ }
+
+ $path = $this->getChangelogFile($appId, $languageCode);
+ if ($path === null) {
+ $this->logger->debug('No changelog file found for app ' . $appId . ' and language code ' . $languageCode);
+ return null;
+ }
+
+ $changes = $this->retrieveChangelogEntry($path, $version);
+ return $changes;
+ }
+
+ /**
+ * Get the changelog file in the requested language or fallback to English
+ * @param string $appId The app to load the changelog for
+ * @param string $languageCode The language code to search
+ * @return string|null Either the file path or null if not found
+ */
+ public function getChangelogFile(string $appId, string $languageCode): string|null {
+ try {
+ $appPath = $this->appManager->getAppPath($appId);
+ $files = ["CHANGELOG.$languageCode.md", 'CHANGELOG.en.md'];
+ foreach ($files as $file) {
+ $path = $appPath . '/' . $file;
+ if (is_file($path)) {
+ return $path;
+ }
+ }
+ } catch (\Throwable $e) {
+ // ignore and return null below
+ }
+ return null;
+ }
+
+ /**
+ * Retrieve a log entry from the changelog
+ * @param string $path The path to the changlog file
+ * @param string $version The version to query (make sure to only pass in "{major}.{minor}(.{patch}" format)
+ */
+ protected function retrieveChangelogEntry(string $path, string $version): string|null {
+ $matches = [];
+ $content = file_get_contents($path);
+ if ($content === false) {
+ $this->logger->debug('Could not open changelog file', ['file-path' => $path]);
+ return null;
+ }
+
+ $result = preg_match_all('/^## (?:\[)?(?:v)?(\d+\.\d+(\.\d+)?)/m', $content, $matches, PREG_OFFSET_CAPTURE);
+ if ($result === false || $result === 0) {
+ $this->logger->debug('No entries in changelog found', ['file_path' => $path]);
+ return null;
+ }
+
+ // Get the key of the match that equals the requested version
+ $index = array_key_first(
+ // Get the array containing the match that equals the requested version, keys are preserved so: [1 => '1.2.4']
+ array_filter(
+ // This is the array of the versions found, like ['1.2.3', '1.2.4']
+ $matches[1],
+ // Callback to filter only version that matches the requested version
+ fn (array $match) => version_compare($match[0], $version, '=='),
+ )
+ );
+
+ if ($index === null) {
+ $this->logger->debug('No changelog entry for version ' . $version . ' found', ['file_path' => $path]);
+ return null;
+ }
+
+ $offsetChangelogEntry = $matches[0][$index][1];
+ // Length of the changelog entry (offset of next match - own offset) or null if the whole rest should be considered
+ $lengthChangelogEntry = $index < ($result - 1) ? ($matches[0][$index + 1][1] - $offsetChangelogEntry) : null;
+ return substr($content, $offsetChangelogEntry, $lengthChangelogEntry);
+ }
+}
diff --git a/apps/updatenotification/lib/Notification/AppUpdateNotifier.php b/apps/updatenotification/lib/Notification/AppUpdateNotifier.php
new file mode 100644
index 00000000000..9fb40e76a13
--- /dev/null
+++ b/apps/updatenotification/lib/Notification/AppUpdateNotifier.php
@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
+ *
+ * @author Ferdinand Thiessen <opensource@fthiessen.de>
+ * @author Joas Schilling <coding@schilljs.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\UpdateNotification\Notification;
+
+use OCA\UpdateNotification\AppInfo\Application;
+use OCP\App\IAppManager;
+use OCP\IURLGenerator;
+use OCP\IUserManager;
+use OCP\L10N\IFactory;
+use OCP\Notification\IAction;
+use OCP\Notification\IManager as INotificationManager;
+use OCP\Notification\INotification;
+use OCP\Notification\INotifier;
+use Psr\Log\LoggerInterface;
+
+class AppUpdateNotifier implements INotifier {
+
+ public function __construct(
+ private IFactory $l10nFactory,
+ private INotificationManager $notificationManager,
+ private IUserManager $userManager,
+ private IURLGenerator $urlGenerator,
+ private IAppManager $appManager,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ public function getID(): string {
+ return 'updatenotification_app_updated';
+ }
+
+ /**
+ * Human readable name describing the notifier
+ */
+ public function getName(): string {
+ return $this->l10nFactory->get(Application::APP_NAME)->t('App updated');
+ }
+
+ /**
+ * @param INotification $notification
+ * @param string $languageCode The code of the language that should be used to prepare the notification
+ * @return INotification
+ * @throws \InvalidArgumentException When the notification was not prepared by a notifier
+ */
+ public function prepare(INotification $notification, string $languageCode): INotification {
+ if ($notification->getApp() !== Application::APP_NAME) {
+ throw new \InvalidArgumentException('Unknown app');
+ }
+
+ if ($notification->getSubject() !== 'app_updated') {
+ throw new \InvalidArgumentException('Unknown subject');
+ }
+
+ $appId = $notification->getSubjectParameters()[0];
+ $appInfo = $this->appManager->getAppInfo($appId, lang:$languageCode);
+ if ($appInfo === null) {
+ throw new \InvalidArgumentException('App info not found');
+ }
+
+ // Prepare translation factory for requested language
+ $l = $this->l10nFactory->get(Application::APP_NAME, $languageCode);
+
+ // See if we can find the app icon - if not fall back to default icon
+ $possibleIcons = [$appId . '-dark.svg', 'app-dark.svg', $appId . '.svg', 'app.svg'];
+ $icon = null;
+ foreach ($possibleIcons as $iconName) {
+ try {
+ $icon = $this->urlGenerator->imagePath($appId, $iconName);
+ } catch (\RuntimeException $e) {
+ // ignore
+ }
+ }
+ if ($icon === null) {
+ $icon = $this->urlGenerator->imagePath('core', 'default-app-icon');
+ }
+
+ $action = $notification->createAction();
+ $action
+ ->setLabel($l->t('See what\'s new'))
+ ->setParsedLabel($l->t('See what\'s new'))
+ ->setLink($this->urlGenerator->linkToRouteAbsolute('updatenotification.Changelog.showChangelog', ['app' => $appId, 'version' => $this->appManager->getAppVersion($appId)]), IAction::TYPE_WEB);
+
+ $notification
+ ->setIcon($this->urlGenerator->getAbsoluteURL($icon))
+ ->addParsedAction($action)
+ ->setRichSubject(
+ $l->t('{app} updated to version {version}'),
+ [
+ 'app' => [
+ 'type' => 'app',
+ 'id' => $appId,
+ 'name' => $appInfo['name'],
+ ],
+ 'version' => [
+ 'type' => 'highlight',
+ 'id' => $appId,
+ 'name' => $appInfo['version'],
+ ],
+ ],
+ );
+
+ return $notification;
+ }
+}
diff --git a/apps/updatenotification/lib/Notification/Notifier.php b/apps/updatenotification/lib/Notification/Notifier.php
index add5437655f..0aace555092 100644
--- a/apps/updatenotification/lib/Notification/Notifier.php
+++ b/apps/updatenotification/lib/Notification/Notifier.php
@@ -114,6 +114,10 @@ class Notifier implements INotifier {
throw new \InvalidArgumentException('Unknown app id');
}
+ if ($notification->getSubject() !== 'update_available' && $notification->getSubject() !== 'connection_error') {
+ throw new \InvalidArgumentException('Unknown subject');
+ }
+
$l = $this->l10NFactory->get('updatenotification', $languageCode);
if ($notification->getSubject() === 'connection_error') {
$errors = (int) $this->config->getAppValue('updatenotification', 'update_check_errors', '0');
@@ -124,40 +128,42 @@ class Notifier implements INotifier {
$notification->setParsedSubject($l->t('The update server could not be reached since %d days to check for new updates.', [$errors]))
->setParsedMessage($l->t('Please check the Nextcloud and server log files for errors.'));
- } elseif ($notification->getObjectType() === 'core') {
- $this->updateAlreadyInstalledCheck($notification, $this->getCoreVersions());
-
- $parameters = $notification->getSubjectParameters();
- $notification->setParsedSubject($l->t('Update to %1$s is available.', [$parameters['version']]))
- ->setRichSubject($l->t('Update to {serverAndVersion} is available.'), [
- 'serverAndVersion' => [
- 'type' => 'highlight',
+ } else {
+ if ($notification->getObjectType() === 'core') {
+ $this->updateAlreadyInstalledCheck($notification, $this->getCoreVersions());
+
+ $parameters = $notification->getSubjectParameters();
+ $notification->setParsedSubject($l->t('Update to %1$s is available.', [$parameters['version']]))
+ ->setRichSubject($l->t('Update to {serverAndVersion} is available.'), [
+ 'serverAndVersion' => [
+ 'type' => 'highlight',
+ 'id' => $notification->getObjectType(),
+ 'name' => $parameters['version'],
+ ]
+ ]);
+
+ if ($this->isAdmin()) {
+ $notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'overview']) . '#version');
+ }
+ } else {
+ $appInfo = $this->getAppInfo($notification->getObjectType(), $languageCode);
+ $appName = ($appInfo === null) ? $notification->getObjectType() : $appInfo['name'];
+
+ if (isset($this->appVersions[$notification->getObjectType()])) {
+ $this->updateAlreadyInstalledCheck($notification, $this->appVersions[$notification->getObjectType()]);
+ }
+
+ $notification->setRichSubject($l->t('Update for {app} to version %s is available.', [$notification->getObjectId()]), [
+ 'app' => [
+ 'type' => 'app',
'id' => $notification->getObjectType(),
- 'name' => $parameters['version'],
+ 'name' => $appName,
]
]);
- if ($this->isAdmin()) {
- $notification->setLink($this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'overview']) . '#version');
- }
- } else {
- $appInfo = $this->getAppInfo($notification->getObjectType(), $languageCode);
- $appName = ($appInfo === null) ? $notification->getObjectType() : $appInfo['name'];
-
- if (isset($this->appVersions[$notification->getObjectType()])) {
- $this->updateAlreadyInstalledCheck($notification, $this->appVersions[$notification->getObjectType()]);
- }
-
- $notification->setRichSubject($l->t('Update for {app} to version %s is available.', [$notification->getObjectId()]), [
- 'app' => [
- 'type' => 'app',
- 'id' => $notification->getObjectType(),
- 'name' => $appName,
- ]
- ]);
-
- if ($this->isAdmin()) {
- $notification->setLink($this->url->linkToRouteAbsolute('settings.AppSettings.viewApps', ['category' => 'updates']) . '#app-' . $notification->getObjectType());
+ if ($this->isAdmin()) {
+ $notification->setLink($this->url->linkToRouteAbsolute('settings.AppSettings.viewApps', ['category' => 'updates']) . '#app-' . $notification->getObjectType());
+ }
}
}
diff --git a/apps/updatenotification/openapi.json b/apps/updatenotification/openapi.json
index ba6065ea47c..94ed779d2c8 100644
--- a/apps/updatenotification/openapi.json
+++ b/apps/updatenotification/openapi.json
@@ -3,7 +3,7 @@
"info": {
"title": "updatenotification",
"version": "0.0.1",
- "description": "Displays update notifications for Nextcloud and provides the SSO for the updater.",
+ "description": "Displays update notifications for Nextcloud, app updates, and provides the SSO for the updater.",
"license": {
"name": "agpl"
}
@@ -203,6 +203,144 @@
}
}
}
+ },
+ "/ocs/v2.php/apps/updatenotification/api/{apiVersion}/changelog/{appId}": {
+ "get": {
+ "operationId": "api-get-app-changelog-entry",
+ "summary": "Get changelog entry for an app",
+ "description": "This endpoint requires admin access",
+ "tags": [
+ "api"
+ ],
+ "security": [
+ {
+ "bearer_auth": []
+ },
+ {
+ "basic_auth": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "version",
+ "in": "query",
+ "description": "The version to search the changelog entry for (defaults to the latest installed)",
+ "schema": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ {
+ "name": "apiVersion",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "v1"
+ ],
+ "default": "v1"
+ }
+ },
+ {
+ "name": "appId",
+ "in": "path",
+ "description": "App to search changelog entry for",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "OCS-APIRequest",
+ "in": "header",
+ "description": "Required to be true for the API request to pass",
+ "required": true,
+ "schema": {
+ "type": "boolean",
+ "default": true
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Changelog entry returned",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "type": "object",
+ "required": [
+ "appName",
+ "content",
+ "version"
+ ],
+ "properties": {
+ "appName": {
+ "type": "string"
+ },
+ "content": {
+ "type": "string"
+ },
+ "version": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No changelog found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "ocs"
+ ],
+ "properties": {
+ "ocs": {
+ "type": "object",
+ "required": [
+ "meta",
+ "data"
+ ],
+ "properties": {
+ "meta": {
+ "$ref": "#/components/schemas/OCSMeta"
+ },
+ "data": {
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
},
"tags": []
diff --git a/apps/updatenotification/src/components/AppChangelogDialog.vue b/apps/updatenotification/src/components/AppChangelogDialog.vue
new file mode 100644
index 00000000000..5171f3e122c
--- /dev/null
+++ b/apps/updatenotification/src/components/AppChangelogDialog.vue
@@ -0,0 +1,96 @@
+<template>
+ <NcDialog content-classes="app-changelog-dialog"
+ :buttons="dialogButtons"
+ :name="t('updatenotification', 'What\'s new in {app} {version}', { app: appName, version: appVersion })"
+ :open="open && markdown !== undefined"
+ size="normal"
+ @update:open="$emit('update:open', $event)">
+ <Markdown class="app-changelog-dialog__text" :markdown="markdown" :min-heading-level="3" />
+ </NcDialog>
+</template>
+
+<script setup lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+import { generateOcsUrl } from '@nextcloud/router'
+import { ref, watchEffect } from 'vue'
+
+import axios from '@nextcloud/axios'
+import NcDialog from '@nextcloud/vue/dist/Components/NcDialog.js'
+import Markdown from './Markdown.vue'
+
+const props = withDefaults(
+ defineProps<{
+ appId: string
+ version?: string
+ open?: boolean
+ }>(),
+
+ // Default values
+ {
+ open: true,
+ version: undefined,
+ },
+)
+
+const emit = defineEmits<{
+ /**
+ * Event that is called when the "Get started"-button is pressed
+ */
+ (e: 'dismiss'): void
+
+ (e: 'update:open', v: boolean): void
+}>()
+
+const dialogButtons = [
+ {
+ label: t('updatenotification', 'Give feedback'),
+ callback: () => {
+ window.open(`https://apps.nextcloud.com/apps/${props.appId}#comments`, '_blank', 'noreferrer noopener')
+ },
+ },
+ {
+ label: t('updatenotification', 'Get started'),
+ type: 'primary',
+ callback: () => {
+ emit('dismiss')
+ emit('update:open', false)
+ },
+ },
+]
+
+const appName = ref(props.appId)
+const appVersion = ref(props.version ?? '')
+const markdown = ref<string>('')
+watchEffect(() => {
+ const url = props.version
+ ? generateOcsUrl('/apps/updatenotification/api/v1/changelog/{app}?version={version}', { version: props.version, app: props.appId })
+ : generateOcsUrl('/apps/updatenotification/api/v1/changelog/{app}', { version: props.version, app: props.appId })
+
+ axios.get(url)
+ .then(({ data }) => {
+ appName.value = data.ocs.data.appName
+ appVersion.value = data.ocs.data.version
+ markdown.value = data.ocs.data.content
+ })
+ .catch((error) => {
+ if (error?.response?.status === 404) {
+ appName.value = props.appId
+ markdown.value = t('updatenotification', 'No changelog available')
+ } else {
+ console.error('Failed to load changelog entry', error)
+ emit('update:open', false)
+ }
+ })
+
+})
+</script>
+
+<style scoped lang="scss">
+:deep(.app-changelog-dialog) {
+ min-height: 50vh !important;
+}
+
+.app-changelog-dialog__text {
+ padding-inline: 14px;
+}
+</style>
diff --git a/apps/updatenotification/src/components/Markdown.vue b/apps/updatenotification/src/components/Markdown.vue
new file mode 100644
index 00000000000..ce69477a1bf
--- /dev/null
+++ b/apps/updatenotification/src/components/Markdown.vue
@@ -0,0 +1,56 @@
+<template>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <div class="markdown" v-html="html" />
+</template>
+
+<script setup lang="ts">
+import { toRef } from 'vue'
+import { useMarkdown } from '../composables/useMarkdown'
+
+const props = withDefaults(
+ defineProps<{
+ markdown: string
+ minHeadingLevel?: 1|2|3|4|5|6
+ }>(),
+ {
+ minHeadingLevel: 2,
+ },
+)
+
+const { html } = useMarkdown(toRef(props, 'markdown'), toRef(props, 'minHeadingLevel'))
+</script>
+
+<style scoped lang="scss">
+.markdown {
+ :deep {
+ ul {
+ list-style: disc;
+ padding-inline-start: 20px;
+ }
+
+ h3, h4, h5, h6 {
+ font-weight: 600;
+ line-height: 1.5;
+ margin-top: 24px;
+ margin-bottom: 12px;
+ color: var(--color-main-text);
+ }
+
+ h3 {
+ font-size: 20px;
+ }
+
+ h4 {
+ font-size: 18px;
+ }
+
+ h5 {
+ font-size: 17px;
+ }
+
+ h6 {
+ font-size: var(--default-font-size);
+ }
+ }
+}
+</style>
diff --git a/apps/updatenotification/src/composables/useMarkdown.ts b/apps/updatenotification/src/composables/useMarkdown.ts
new file mode 100644
index 00000000000..1fd97335ed6
--- /dev/null
+++ b/apps/updatenotification/src/composables/useMarkdown.ts
@@ -0,0 +1,62 @@
+import type { Ref } from 'vue'
+
+import { marked } from 'marked'
+import { computed } from 'vue'
+import dompurify from 'dompurify'
+
+export const useMarkdown = (text: Ref<string|undefined|null>, minHeadingLevel: Ref<number|undefined>) => {
+ const minHeading = computed(() => Math.min(Math.max(minHeadingLevel.value ?? 1, 1), 6))
+ const renderer = new marked.Renderer()
+
+ renderer.link = function(href, title, text) {
+ let out = `<a href="${href}" rel="noreferrer noopener" target="_blank"`
+ if (title) {
+ out += ' title="' + title + '"'
+ }
+ out += '>' + text + '</a>'
+ return out
+ }
+
+ renderer.image = function(href, title, text) {
+ if (text) {
+ return text
+ }
+ return title ?? ''
+ }
+
+ renderer.heading = (text: string, level: number) => {
+ const headingLevel = Math.max(minHeading.value, level)
+ return `<h${headingLevel}>${text}</h${headingLevel}>`
+ }
+
+ const html = computed(() => dompurify.sanitize(
+ marked((text.value ?? '').trim(), {
+ renderer,
+ gfm: false,
+ breaks: false,
+ pedantic: false,
+ }),
+ {
+ SAFE_FOR_JQUERY: true,
+ ALLOWED_TAGS: [
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'strong',
+ 'p',
+ 'a',
+ 'ul',
+ 'ol',
+ 'li',
+ 'em',
+ 'del',
+ 'blockquote',
+ ],
+ },
+ ))
+
+ return { html }
+}
diff --git a/apps/updatenotification/src/init.ts b/apps/updatenotification/src/init.ts
new file mode 100644
index 00000000000..a2421340982
--- /dev/null
+++ b/apps/updatenotification/src/init.ts
@@ -0,0 +1,75 @@
+import { subscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { generateOcsUrl } from '@nextcloud/router'
+import Vue, { defineAsyncComponent } from 'vue'
+import axios from '@nextcloud/axios'
+
+const navigationEntries = loadState('core', 'apps', {})
+
+const DialogVue = defineAsyncComponent(() => import('./components/AppChangelogDialog.vue'))
+
+/**
+ * Show the app changelog dialog
+ *
+ * @param appId The app to show the changelog for
+ * @param version Optional version to show
+ */
+function showDialog(appId: string, version?: string) {
+ const element = document.createElement('div')
+ document.body.appendChild(element)
+
+ return new Promise((resolve) => {
+ let dismissed = false
+
+ const dialog = new Vue({
+ el: element,
+ render: (h) => h(DialogVue, {
+ props: {
+ appId,
+ version,
+ },
+ on: {
+ dismiss: () => { dismissed = true },
+ 'update:open': (open: boolean) => {
+ if (!open) {
+ dialog.$destroy?.()
+ resolve(dismissed)
+
+ if (dismissed && appId in navigationEntries) {
+ window.location = navigationEntries[appId].href
+ }
+ }
+ },
+ },
+ }),
+ })
+ })
+}
+
+interface INotificationActionEvent {
+ cancelAction: boolean
+ notification: Readonly<{
+ notificationId: number
+ objectId: string
+ objectType: string
+ }>
+ action: Readonly<{
+ url: string
+ type: 'WEB'|'GET'|'POST'|'DELETE'
+ }>,
+}
+
+subscribe('notifications:action:execute', (event: INotificationActionEvent) => {
+ if (event.notification.objectType === 'app_updated') {
+ event.cancelAction = true
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const [_, app, version, __] = event.action.url.match(/(?<=\/)([^?]+)?version=((\d+.?)+)/) ?? []
+ showDialog((app as string|undefined) || (event.notification.objectId as string), version)
+ .then((dismissed) => {
+ if (dismissed) {
+ axios.delete(generateOcsUrl('apps/notifications/api/v2/notifications/{id}', { id: event.notification.notificationId }))
+ }
+ })
+ }
+})
diff --git a/apps/updatenotification/src/init.js b/apps/updatenotification/src/updatenotification.js
index 8d0e18e1bf1..8d0e18e1bf1 100644
--- a/apps/updatenotification/src/init.js
+++ b/apps/updatenotification/src/updatenotification.js
diff --git a/apps/updatenotification/src/view-changelog-page.ts b/apps/updatenotification/src/view-changelog-page.ts
new file mode 100644
index 00000000000..dc429bd5a1c
--- /dev/null
+++ b/apps/updatenotification/src/view-changelog-page.ts
@@ -0,0 +1,8 @@
+import Vue from 'vue'
+import App from './views/App.vue'
+
+export default new Vue({
+ name: 'ViewChangelogPage',
+ render: (h) => h(App),
+ el: '#content',
+})
diff --git a/apps/updatenotification/src/views/App.vue b/apps/updatenotification/src/views/App.vue
new file mode 100644
index 00000000000..7d5a04db104
--- /dev/null
+++ b/apps/updatenotification/src/views/App.vue
@@ -0,0 +1,39 @@
+<template>
+ <NcContent app-name="updatenotification">
+ <NcAppContent :page-heading="t('updatenotification', 'Changelog for app {app}', { app: appName })">
+ <div class="changelog__wrapper">
+ <h2 class="changelog__heading">
+ {{ t('updatenotification', 'What\'s new in {app} version {version}', { app: appName, version: appVersion }) }}
+ </h2>
+ <Markdown :markdown="markdown" :min-heading-level="3" />
+ </div>
+ </NcAppContent>
+ </NcContent>
+</template>
+
+<script setup lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+import { loadState } from '@nextcloud/initial-state'
+
+import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
+import NcContent from '@nextcloud/vue/dist/Components/NcContent.js'
+import Markdown from '../components/Markdown.vue'
+
+const {
+ appName,
+ appVersion,
+ text: markdown,
+} = loadState<{ appName: string, appVersion: string, text: string }>('updatenotification', 'changelog')
+</script>
+
+<style scoped>
+.changelog__wrapper {
+ max-width: max(50vw,700px);
+ margin-inline: auto;
+}
+
+.changelog__heading {
+ font-size: 30px;
+ margin-block: var(--app-navigation-padding, 8px) 1em;
+}
+</style>
diff --git a/apps/updatenotification/templates/empty.php b/apps/updatenotification/templates/empty.php
new file mode 100644
index 00000000000..e1f865bd26c
--- /dev/null
+++ b/apps/updatenotification/templates/empty.php
@@ -0,0 +1,4 @@
+<?php
+/**
+ * Empty as Vue will take over
+ */
diff --git a/webpack.modules.js b/webpack.modules.js
index 4b258c58ac2..676646ff73b 100644
--- a/webpack.modules.js
+++ b/webpack.modules.js
@@ -114,7 +114,9 @@ module.exports = {
settings: path.join(__dirname, 'apps/twofactor_backupcodes/src', 'settings.js'),
},
updatenotification: {
- updatenotification: path.join(__dirname, 'apps/updatenotification/src', 'init.js'),
+ init: path.join(__dirname, 'apps/updatenotification/src', 'init.ts'),
+ 'view-changelog-page': path.join(__dirname, 'apps/updatenotification/src', 'view-changelog-page.ts'),
+ updatenotification: path.join(__dirname, 'apps/updatenotification/src', 'updatenotification.js'),
},
user_status: {
menu: path.join(__dirname, 'apps/user_status/src', 'menu.js'),