mirror of https://github.com/nextcloud/server.git
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
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