mirror of https://github.com/nextcloud/server.git
Merge pull request #43967 from nextcloud/feat/app-updated-notification
feat: Provide app-updated notifications for userspull/44062/head
commit
cf888e9723
@ -0,0 +1,126 @@
|
||||
<?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\Services\IAppConfig;
|
||||
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 IAppConfig $appConfig,
|
||||
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 = $this->appConfig->getAppValueBool('app_updated.notify_guests', false) && 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->getBackendClassName() === '\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,78 @@
|
||||
<?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 OCP\IAppConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/** @template-implements IEventListener<AppUpdateEvent> */
|
||||
class AppUpdateEventListener implements IEventListener {
|
||||
|
||||
public function __construct(
|
||||
private IJobList $jobList,
|
||||
private LoggerInterface $logger,
|
||||
private IAppConfig $appConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AppUpdateEvent $event
|
||||
*/
|
||||
public function handle(Event $event): void {
|
||||
if (!($event instanceof AppUpdateEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->appConfig->getValueBool(Application::APP_NAME, 'app_updated.enabled', true)) {
|
||||
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,70 @@
|
||||
<?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 OCP\IAppConfig;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
|
||||
class BeforeTemplateRenderedEventListener implements IEventListener {
|
||||
|
||||
public function __construct(
|
||||
private IAppManager $appManager,
|
||||
private LoggerInterface $logger,
|
||||
private IAppConfig $appConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param BeforeTemplateRenderedEvent $event
|
||||
*/
|
||||
public function handle(Event $event): void {
|
||||
if (!($event instanceof BeforeTemplateRenderedEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->appConfig->getValueBool(Application::APP_NAME, 'app_updated.enabled', true)) {
|
||||
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,73 @@
|
||||
<?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\Migration;
|
||||
|
||||
use OCA\UpdateNotification\BackgroundJob\ResetToken;
|
||||
use OCP\BackgroundJob\IJobList;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
/**
|
||||
* Drop this with Nextcloud 30
|
||||
*/
|
||||
class Version011901Date20240305120000 extends SimpleMigrationStep {
|
||||
|
||||
public function __construct(
|
||||
private IJobList $joblist,
|
||||
) {
|
||||
}
|
||||
|
||||
public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options): void {
|
||||
/**
|
||||
* Remove and replace the reset-updater-token background job
|
||||
* This class was renamed so it is now unknow but we still need to remove it
|
||||
* @psalm-suppress UndefinedClass, InvalidArgument
|
||||
*/
|
||||
$hasOldResetToken = $this->joblist->has(\OCA\UpdateNotification\ResetTokenBackgroundJob::class, null);
|
||||
$hasNewResetToken = $this->joblist->has(ResetToken::class, null);
|
||||
if ($hasOldResetToken) {
|
||||
/**
|
||||
* @psalm-suppress UndefinedClass, InvalidArgument
|
||||
*/
|
||||
$this->joblist->remove(\OCA\UpdateNotification\ResetTokenBackgroundJob::class);
|
||||
if (!$hasNewResetToken) {
|
||||
$this->joblist->add(ResetToken::class);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the "has updates" background job, the new one is automatically started from the info.xml
|
||||
* @psalm-suppress UndefinedClass, InvalidArgument
|
||||
*/
|
||||
if ($this->joblist->has(\OCA\UpdateNotification\Notification\BackgroundJob::class, null)) {
|
||||
/**
|
||||
* @psalm-suppress UndefinedClass, InvalidArgument
|
||||
*/
|
||||
$this->joblist->remove(\OCA\UpdateNotification\Notification\BackgroundJob::class);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,119 @@
|
||||
<?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);
|
||||
|
||||
$icon = $this->appManager->getAppIcon($appId);
|
||||
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
|
||||
*/
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,2 @@
|
||||
(()=>{"use strict";var e,t,r,o={38248:(e,t,r)=>{var o=r(61338),n=r(38613),i=r(99498),a=r(85471),c=r(26287);const l=(0,n.C)("core","apps",{}),d=(0,a.$V)((()=>Promise.all([r.e(4208),r.e(6794)]).then(r.bind(r,6794))));(0,o.B1)("notifications:action:execute",(e=>{if("app_updated"===e.notification.objectType){var t;e.cancelAction=!0;const[r,o,n,s]=null!==(t=e.action.url.match(/(?<=\/)([^?]+)?version=((\d+.?)+)/))&&void 0!==t?t:[];(function(e,t){const r=document.createElement("div");return document.body.appendChild(r),new Promise((o=>{let n=!1;const i=new a.Ay({el:r,render:r=>r(d,{props:{appId:e,version:t},on:{dismiss:()=>{n=!0},"update:open":t=>{var r;t||(null===(r=i.$destroy)||void 0===r||r.call(i),o(n),n&&e in l&&(window.location=l[e].href))}}})})}))})(o||e.notification.objectId,n).then((t=>{t&&c.A.delete((0,i.KT)("apps/notifications/api/v2/notifications/{id}",{id:e.notification.notificationId}))}))}}))}},n={};function i(e){var t=n[e];if(void 0!==t)return t.exports;var r=n[e]={id:e,loaded:!1,exports:{}};return o[e].call(r.exports,r,r.exports,i),r.loaded=!0,r.exports}i.m=o,e=[],i.O=(t,r,o,n)=>{if(!r){var a=1/0;for(s=0;s<e.length;s++){r=e[s][0],o=e[s][1],n=e[s][2];for(var c=!0,l=0;l<r.length;l++)(!1&n||a>=n)&&Object.keys(i.O).every((e=>i.O[e](r[l])))?r.splice(l--,1):(c=!1,n<a&&(a=n));if(c){e.splice(s--,1);var d=o();void 0!==d&&(t=d)}}return t}n=n||0;for(var s=e.length;s>0&&e[s-1][2]>n;s--)e[s]=e[s-1];e[s]=[r,o,n]},i.n=e=>{var t=e&&e.__esModule?()=>e.default:()=>e;return i.d(t,{a:t}),t},i.d=(e,t)=>{for(var r in t)i.o(t,r)&&!i.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},i.f={},i.e=e=>Promise.all(Object.keys(i.f).reduce(((t,r)=>(i.f[r](e,t),t)),[])),i.u=e=>e+"-"+e+".js?v=0dcea5eda96c3f05a6f1",i.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(e){if("object"==typeof window)return window}}(),i.o=(e,t)=>Object.prototype.hasOwnProperty.call(e,t),t={},r="nextcloud:",i.l=(e,o,n,a)=>{if(t[e])t[e].push(o);else{var c,l;if(void 0!==n)for(var d=document.getElementsByTagName("script"),s=0;s<d.length;s++){var u=d[s];if(u.getAttribute("src")==e||u.getAttribute("data-webpack")==r+n){c=u;break}}c||(l=!0,(c=document.createElement("script")).charset="utf-8",c.timeout=120,i.nc&&c.setAttribute("nonce",i.nc),c.setAttribute("data-webpack",r+n),c.src=e),t[e]=[o];var p=(r,o)=>{c.onerror=c.onload=null,clearTimeout(f);var n=t[e];if(delete t[e],c.parentNode&&c.parentNode.removeChild(c),n&&n.forEach((e=>e(o))),r)return r(o)},f=setTimeout(p.bind(null,void 0,{type:"timeout",target:c}),12e4);c.onerror=p.bind(null,c.onerror),c.onload=p.bind(null,c.onload),l&&document.head.appendChild(c)}},i.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.nmd=e=>(e.paths=[],e.children||(e.children=[]),e),i.j=1864,(()=>{var e;i.g.importScripts&&(e=i.g.location+"");var t=i.g.document;if(!e&&t&&(t.currentScript&&(e=t.currentScript.src),!e)){var r=t.getElementsByTagName("script");if(r.length)for(var o=r.length-1;o>-1&&(!e||!/^http(s?):/.test(e));)e=r[o--].src}if(!e)throw new Error("Automatic publicPath is not supported in this browser");e=e.replace(/#.*$/,"").replace(/\?.*$/,"").replace(/\/[^\/]+$/,"/"),i.p=e})(),(()=>{i.b=document.baseURI||self.location.href;var e={1864:0};i.f.j=(t,r)=>{var o=i.o(e,t)?e[t]:void 0;if(0!==o)if(o)r.push(o[2]);else{var n=new Promise(((r,n)=>o=e[t]=[r,n]));r.push(o[2]=n);var a=i.p+i.u(t),c=new Error;i.l(a,(r=>{if(i.o(e,t)&&(0!==(o=e[t])&&(e[t]=void 0),o)){var n=r&&("load"===r.type?"missing":r.type),a=r&&r.target&&r.target.src;c.message="Loading chunk "+t+" failed.\n("+n+": "+a+")",c.name="ChunkLoadError",c.type=n,c.request=a,o[1](c)}}),"chunk-"+t,t)}},i.O.j=t=>0===e[t];var t=(t,r)=>{var o,n,a=r[0],c=r[1],l=r[2],d=0;if(a.some((t=>0!==e[t]))){for(o in c)i.o(c,o)&&(i.m[o]=c[o]);if(l)var s=l(i)}for(t&&t(r);d<a.length;d++)n=a[d],i.o(e,n)&&e[n]&&e[n][0](),e[n]=0;return i.O(s)},r=self.webpackChunknextcloud=self.webpackChunknextcloud||[];r.forEach(t.bind(null,0)),r.push=t.bind(null,r.push.bind(r))})(),i.nc=void 0;var a=i.O(void 0,[4208],(()=>i(38248)));a=i.O(a)})();
|
||||
//# sourceMappingURL=updatenotification-init.js.map?v=7318823919577215060f
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue