diff options
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'), |