* 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
parent
d9d3448e23
commit
fa14daf968
@ -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 |
||||
*/ |
||||
Loading…
Reference in new issue