Browse Source
feat(updatenotification): Add notification for users when apps are updated
feat(updatenotification): Add notification for users when apps are updated
* Open app changelog dialog when available (webui) * Fallback to open changelog page for mobile clients Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>pull/43967/head
committed by
John Molakvoæ (skjnldsv)
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
23 changed files with 1194 additions and 66 deletions
-
4apps/updatenotification/appinfo/info.xml
-
3apps/updatenotification/appinfo/routes.php
-
10apps/updatenotification/composer/composer/autoload_classmap.php
-
10apps/updatenotification/composer/composer/autoload_static.php
-
14apps/updatenotification/lib/AppInfo/Application.php
-
124apps/updatenotification/lib/BackgroundJob/AppUpdatedNotifications.php
-
74apps/updatenotification/lib/Controller/APIController.php
-
76apps/updatenotification/lib/Controller/ChangelogController.php
-
72apps/updatenotification/lib/Listener/AppUpdateEventListener.php
-
64apps/updatenotification/lib/Listener/BeforeTemplateRenderedEventListener.php
-
131apps/updatenotification/lib/Manager.php
-
128apps/updatenotification/lib/Notification/AppUpdateNotifier.php
-
66apps/updatenotification/lib/Notification/Notifier.php
-
140apps/updatenotification/openapi.json
-
96apps/updatenotification/src/components/AppChangelogDialog.vue
-
56apps/updatenotification/src/components/Markdown.vue
-
62apps/updatenotification/src/composables/useMarkdown.ts
-
75apps/updatenotification/src/init.ts
-
0apps/updatenotification/src/updatenotification.js
-
8apps/updatenotification/src/view-changelog-page.ts
-
39apps/updatenotification/src/views/App.vue
-
4apps/updatenotification/templates/empty.php
-
4webpack.modules.js
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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'); |
|||
} |
|||
} |
|||
@ -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, |
|||
], |
|||
); |
|||
} |
|||
} |
|||
@ -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'); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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> |
|||
@ -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 } |
|||
} |
|||
@ -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 })) |
|||
} |
|||
}) |
|||
} |
|||
}) |
|||
@ -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', |
|||
}) |
|||
@ -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> |
|||
@ -0,0 +1,4 @@ |
|||
<?php |
|||
/** |
|||
* Empty as Vue will take over |
|||
*/ |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue