Skip to content

Commit 812a407

Browse files
committed
feat: add navigation action allowing creating new events everywhere
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent ba33ef3 commit 812a407

10 files changed

Lines changed: 349 additions & 45 deletions

lib/AppInfo/Application.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010

1111
use OCA\Calendar\Dashboard\CalendarWidget;
1212
use OCA\Calendar\Events\BeforeAppointmentBookedEvent;
13+
use OCA\Calendar\Listener\AppMenuActionListener;
1314
use OCA\Calendar\Listener\AppointmentBookedListener;
1415
use OCA\Calendar\Listener\CalendarReferenceListener;
16+
use OCA\Calendar\Listener\EditorInitialStateListener;
1517
use OCA\Calendar\Listener\NotifyPushListener;
1618
use OCA\Calendar\Listener\UserDeletedListener;
1719
use OCA\Calendar\Notification\Notifier;
@@ -22,11 +24,17 @@
2224
use OCP\AppFramework\Bootstrap\IBootContext;
2325
use OCP\AppFramework\Bootstrap\IBootstrap;
2426
use OCP\AppFramework\Bootstrap\IRegistrationContext;
27+
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
2528
use OCP\Calendar\Events\CalendarObjectCreatedEvent;
2629
use OCP\Calendar\Events\CalendarObjectDeletedEvent;
2730
use OCP\Calendar\Events\CalendarObjectUpdatedEvent;
2831
use OCP\Collaboration\Reference\RenderReferenceEvent;
32+
use OCP\INavigationManager;
33+
use OCP\IURLGenerator;
2934
use OCP\IUserSession;
35+
use OCP\L10N\IFactory;
36+
use OCP\Navigation\Events\LoadAdditionalEntriesEvent;
37+
use OCP\ServerVersion;
3038
use OCP\User\Events\UserDeletedEvent;
3139
use OCP\Util;
3240
use Psr\Container\ContainerInterface;
@@ -35,6 +43,12 @@ class Application extends App implements IBootstrap {
3543
/** @var string */
3644
public const APP_ID = 'calendar';
3745

46+
/**
47+
* Actions in the app menu, and with it the new event dialog,
48+
* are only supported since Nextcloud 35.
49+
*/
50+
private const APP_MENU_ACTION_VERSION = 35;
51+
3852
/**
3953
* @param array $params
4054
*/
@@ -58,6 +72,12 @@ public function register(IRegistrationContext $context): void {
5872
$context->registerEventListener(BeforeAppointmentBookedEvent::class, AppointmentBookedListener::class);
5973
$context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
6074
$context->registerEventListener(RenderReferenceEvent::class, CalendarReferenceListener::class);
75+
if ($this->hasAppMenuActions()) {
76+
// The editor of the new event dialog can be opened on any page
77+
$context->registerEventListener(BeforeTemplateRenderedEvent::class, EditorInitialStateListener::class);
78+
// The app navigation action
79+
$context->registerEventListener(LoadAdditionalEntriesEvent::class, AppMenuActionListener::class);
80+
}
6181

6282
$context->registerEventListener(CalendarObjectCreatedEvent::class, NotifyPushListener::class);
6383
$context->registerEventListener(CalendarObjectUpdatedEvent::class, NotifyPushListener::class);
@@ -89,4 +109,11 @@ private function addContactsMenuScript(ContainerInterface $container): void {
89109
Util::addScript(self::APP_ID, 'calendar-contacts-menu');
90110
Util::addStyle(self::APP_ID, 'calendar-contacts-menu');
91111
}
112+
113+
/**
114+
* Whether the server supports actions in the app menu.
115+
*/
116+
private function hasAppMenuActions(): bool {
117+
return (new ServerVersion())->getMajorVersion() >= self::APP_MENU_ACTION_VERSION;
118+
}
92119
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Calendar\Listener;
11+
12+
use OCA\Calendar\AppInfo\Application;
13+
use OCP\EventDispatcher\Event;
14+
use OCP\EventDispatcher\IEventListener;
15+
use OCP\IL10N;
16+
use OCP\INavigationManager;
17+
use OCP\IURLGenerator;
18+
use OCP\IUserSession;
19+
use OCP\Navigation\Events\LoadAdditionalEntriesEvent;
20+
use OCP\Util;
21+
22+
/**
23+
* Add the app menu action to create a new event from within any app.
24+
*
25+
* @template-implements IEventListener<Event|LoadAdditionalEntriesEvent>
26+
*/
27+
class AppMenuActionListener implements IEventListener {
28+
public function __construct(
29+
private IL10N $l10n,
30+
private INavigationManager $navigationManager,
31+
private IURLGenerator $urlGenerator,
32+
private IUserSession $userSession,
33+
) {
34+
}
35+
36+
#[\Override]
37+
public function handle(Event $event): void {
38+
if (!$event instanceof LoadAdditionalEntriesEvent) {
39+
return;
40+
}
41+
42+
// Events can only be created for a user
43+
if (!$this->userSession->isLoggedIn()) {
44+
return;
45+
}
46+
47+
$this->navigationManager->add([
48+
'id' => 'calendar:new-event',
49+
'order' => 4,
50+
'icon' => $this->urlGenerator->imagePath(Application::APP_ID, 'calendar.svg'),
51+
'name' => $this->l10n->t('Event'), // TRANSLATORS: This is the label of the action in the app menu to create a new calendar event
52+
'type' => INavigationManager::TYPE_ACTION,
53+
]);
54+
55+
// Handles clicks on the action and spawns the editor
56+
Util::addScript(Application::APP_ID, 'calendar-appMenu');
57+
}
58+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Calendar\Listener;
11+
12+
use OCA\Calendar\Service\CalendarInitialStateService;
13+
use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
14+
use OCP\EventDispatcher\Event;
15+
use OCP\EventDispatcher\IEventListener;
16+
use OCP\IUserSession;
17+
18+
/**
19+
* The new event dialog of the app menu can be opened on any page,
20+
* so the state the editor needs has to be available everywhere.
21+
*
22+
* @template-implements IEventListener<Event|BeforeTemplateRenderedEvent>
23+
*/
24+
class EditorInitialStateListener implements IEventListener {
25+
public function __construct(
26+
private IUserSession $userSession,
27+
private CalendarInitialStateService $calendarInitialStateService,
28+
) {
29+
}
30+
31+
#[\Override]
32+
public function handle(Event $event): void {
33+
if (!$event instanceof BeforeTemplateRenderedEvent) {
34+
return;
35+
}
36+
37+
if (!$this->userSession->isLoggedIn()) {
38+
return;
39+
}
40+
41+
$this->calendarInitialStateService->runForEditor();
42+
}
43+
}

lib/Service/CalendarInitialStateService.php

Lines changed: 58 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
class CalendarInitialStateService {
2525

26+
private bool $editorStateProvided = false;
27+
2628
public function __construct(
2729
private string $appName,
2830
private IInitialState $initialStateService,
@@ -41,50 +43,32 @@ public function __construct(
4143
}
4244

4345
public function run(): void {
46+
$this->runForEditor();
47+
4448
$defaultEventLimit = $this->config->getAppValue($this->appName, 'eventLimit', 'yes');
4549
$defaultInitialView = $this->config->getAppValue($this->appName, 'currentView', 'dayGridMonth');
4650
$defaultShowWeekends = $this->config->getAppValue($this->appName, 'showWeekends', 'yes');
4751
$defaultWeekNumbers = $this->config->getAppValue($this->appName, 'showWeekNr', 'no');
4852
$defaultSkipPopover = $this->config->getAppValue($this->appName, 'skipPopover', 'no');
49-
$defaultTimezone = $this->config->getAppValue($this->appName, 'timezone', 'automatic');
5053
$defaultSlotDuration = $this->config->getAppValue($this->appName, 'slotDuration', '00:30:00');
51-
$defaultDefaultReminder = $this->config->getAppValue($this->appName, 'defaultReminder', 'none');
5254
$defaultShowTasks = $this->config->getAppValue($this->appName, 'showTasks', 'yes');
5355
$defaultTasksSidebar = $this->config->getAppValue($this->appName, 'tasksSidebar', 'yes');
5456

55-
$appVersion = $this->config->getAppValue($this->appName, 'installed_version', '');
5657
$eventLimit = $this->config->getUserValue($this->userId, $this->appName, 'eventLimit', $defaultEventLimit) === 'yes';
5758
$firstRun = $this->config->getUserValue($this->userId, $this->appName, 'firstRun', 'yes') === 'yes';
5859
$initialView = $this->getView($this->config->getUserValue($this->userId, $this->appName, 'currentView', $defaultInitialView));
5960
$showWeekends = $this->config->getUserValue($this->userId, $this->appName, 'showWeekends', $defaultShowWeekends) === 'yes';
6061
$showWeekNumbers = $this->config->getUserValue($this->userId, $this->appName, 'showWeekNr', $defaultWeekNumbers) === 'yes';
6162
$skipPopover = $this->config->getUserValue($this->userId, $this->appName, 'skipPopover', $defaultSkipPopover) === 'yes';
62-
$timezone = $this->config->getUserValue($this->userId, $this->appName, 'timezone', $defaultTimezone);
63-
$attachmentsFolder = $this->config->getUserValue($this->userId, 'dav', 'attachmentsFolder', '/Calendar');
6463
$slotDuration = $this->config->getUserValue($this->userId, $this->appName, 'slotDuration', $defaultSlotDuration);
65-
$defaultReminder = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminder', $defaultDefaultReminder);
66-
$defaultReminderPartDay = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminderPartDay', $defaultReminder);
67-
$defaultReminderFullDay = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminderFullDay', $defaultReminder);
6864
$showTasks = $this->config->getUserValue($this->userId, $this->appName, 'showTasks', $defaultShowTasks) === 'yes';
6965
$tasksSidebar = $this->config->getUserValue($this->userId, $this->appName, 'tasksSidebar', $defaultTasksSidebar) === 'yes';
70-
$hideEventExport = $this->config->getAppValue($this->appName, 'hideEventExport', 'no') === 'yes';
7166
$disableAppointments = $this->config->getAppValue($this->appName, 'disableAppointments', 'no') === 'yes';
72-
$forceEventAlarmType = $this->config->getAppValue($this->appName, 'forceEventAlarmType', '');
73-
if (!in_array($forceEventAlarmType, ['DISPLAY', 'EMAIL'], true)) {
74-
$forceEventAlarmType = false;
75-
}
7667
$canSubscribeLink = $this->config->getAppValue('dav', 'allow_calendar_link_subscriptions', 'yes') === 'yes';
77-
$showResources = $this->config->getAppValue($this->appName, 'showResources', 'yes') === 'yes';
7868
$publicCalendars = $this->config->getAppValue($this->appName, 'publicCalendars', '');
7969

80-
$talkApiVersion = version_compare($this->appManager->getAppVersion('spreed'), '12.0.0', '>=') ? 'v4' : 'v1';
8170
$tasksEnabled = $this->appManager->isEnabledForUser('tasks');
8271

83-
$circleVersion = $this->appManager->getAppVersion('circles');
84-
$isCirclesEnabled = $this->appManager->isEnabledForUser('circles') === true;
85-
// if circles is not installed, we use 0.0.0
86-
$isCircleVersionCompatible = $this->compareVersion->isCompatible($circleVersion ? $circleVersion : '0.0.0', '22');
87-
8872
$calendarFederationEnabled = $this->appConfig->getValueBool(
8973
'dav',
9074
'enableCalendarFederation',
@@ -96,46 +80,84 @@ public function run(): void {
9680
true,
9781
);
9882

99-
$enableResourceBooking = !empty($this->resourceManager->getBackends())
100-
|| !empty($this->roomManager->getBackends());
101-
102-
$this->initialStateService->provideInitialState('app_version', $appVersion);
10383
$this->initialStateService->provideInitialState('event_limit', $eventLimit);
10484
$this->initialStateService->provideInitialState('first_run', $firstRun);
10585
$this->initialStateService->provideInitialState('initial_view', $initialView);
10686
$this->initialStateService->provideInitialState('show_weekends', $showWeekends);
10787
$this->initialStateService->provideInitialState('show_week_numbers', $showWeekNumbers);
10888
$this->initialStateService->provideInitialState('skip_popover', $skipPopover);
109-
$this->initialStateService->provideInitialState('talk_enabled', $this->isTalkEnabledForUser());
110-
$this->initialStateService->provideInitialState('talk_api_version', $talkApiVersion);
111-
$this->initialStateService->provideInitialState('timezone', $timezone);
112-
$this->initialStateService->provideInitialState('attachments_folder', $attachmentsFolder);
11389
$this->initialStateService->provideInitialState('slot_duration', $slotDuration);
114-
$this->initialStateService->provideInitialState('default_reminder', $defaultReminder);
115-
$this->initialStateService->provideInitialState('default_reminder_part_day', $defaultReminderPartDay);
116-
$this->initialStateService->provideInitialState('default_reminder_full_day', $defaultReminderFullDay);
11790
$this->initialStateService->provideInitialState('show_tasks', $showTasks);
11891
$this->initialStateService->provideInitialState('tasks_sidebar', $tasksSidebar);
11992
$this->initialStateService->provideInitialState('tasks_enabled', $tasksEnabled);
120-
$this->initialStateService->provideInitialState('hide_event_export', $hideEventExport);
121-
$this->initialStateService->provideInitialState('force_event_alarm_type', $forceEventAlarmType);
12293
if (!is_null($this->userId)) {
12394
$this->initialStateService->provideInitialState('appointmentConfigs', $this->appointmentConfigService->getAllAppointmentConfigurations($this->userId));
12495
}
12596
$this->initialStateService->provideInitialState('disable_appointments', $disableAppointments);
12697
$this->initialStateService->provideInitialState('can_subscribe_link', $canSubscribeLink);
127-
$this->initialStateService->provideInitialState('show_resources', $showResources);
128-
$this->initialStateService->provideInitialState('isCirclesEnabled', $isCirclesEnabled && $isCircleVersionCompatible);
12998
$this->initialStateService->provideInitialState('publicCalendars', $publicCalendars);
13099
$this->initialStateService->provideInitialState(
131100
'calendar_federation_enabled',
132101
$calendarFederationEnabled && $remoteSharesEnabled,
133102
);
103+
$this->initialStateService->provideInitialState('has_notify_push', $this->queue !== null);
104+
}
105+
106+
/**
107+
* Provide the state required by the event editor.
108+
*
109+
* This is the subset of {@see self::run()} needed on pages that do not render
110+
* the calendar itself, but can spawn the editor - like the new event dialog of
111+
* the app menu, which is available in every app.
112+
*/
113+
public function runForEditor(): void {
114+
if ($this->editorStateProvided) {
115+
return;
116+
}
117+
$this->editorStateProvided = true;
118+
119+
$defaultTimezone = $this->config->getAppValue($this->appName, 'timezone', 'automatic');
120+
$defaultDefaultReminder = $this->config->getAppValue($this->appName, 'defaultReminder', 'none');
121+
122+
$appVersion = $this->config->getAppValue($this->appName, 'installed_version', '');
123+
$timezone = $this->config->getUserValue($this->userId, $this->appName, 'timezone', $defaultTimezone);
124+
$attachmentsFolder = $this->config->getUserValue($this->userId, 'dav', 'attachmentsFolder', '/Calendar');
125+
$defaultReminder = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminder', $defaultDefaultReminder);
126+
$defaultReminderPartDay = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminderPartDay', $defaultReminder);
127+
$defaultReminderFullDay = $this->config->getUserValue($this->userId, $this->appName, 'defaultReminderFullDay', $defaultReminder);
128+
$hideEventExport = $this->config->getAppValue($this->appName, 'hideEventExport', 'no') === 'yes';
129+
$showResources = $this->config->getAppValue($this->appName, 'showResources', 'yes') === 'yes';
130+
$forceEventAlarmType = $this->config->getAppValue($this->appName, 'forceEventAlarmType', '');
131+
if (!in_array($forceEventAlarmType, ['DISPLAY', 'EMAIL'], true)) {
132+
$forceEventAlarmType = false;
133+
}
134+
135+
$talkApiVersion = version_compare($this->appManager->getAppVersion('spreed'), '12.0.0', '>=') ? 'v4' : 'v1';
136+
137+
$circleVersion = $this->appManager->getAppVersion('circles');
138+
$isCirclesEnabled = $this->appManager->isEnabledForUser('circles') === true;
139+
// if circles is not installed, we use 0.0.0
140+
$isCircleVersionCompatible = $this->compareVersion->isCompatible($circleVersion ? $circleVersion : '0.0.0', '22');
141+
142+
$enableResourceBooking = !empty($this->resourceManager->getBackends())
143+
|| !empty($this->roomManager->getBackends());
144+
145+
$this->initialStateService->provideInitialState('app_version', $appVersion);
146+
$this->initialStateService->provideInitialState('timezone', $timezone);
147+
$this->initialStateService->provideInitialState('attachments_folder', $attachmentsFolder);
148+
$this->initialStateService->provideInitialState('default_reminder', $defaultReminder);
149+
$this->initialStateService->provideInitialState('default_reminder_part_day', $defaultReminderPartDay);
150+
$this->initialStateService->provideInitialState('default_reminder_full_day', $defaultReminderFullDay);
151+
$this->initialStateService->provideInitialState('hide_event_export', $hideEventExport);
152+
$this->initialStateService->provideInitialState('show_resources', $showResources);
153+
$this->initialStateService->provideInitialState('force_event_alarm_type', $forceEventAlarmType);
154+
$this->initialStateService->provideInitialState('talk_enabled', $this->isTalkEnabledForUser());
155+
$this->initialStateService->provideInitialState('talk_api_version', $talkApiVersion);
156+
$this->initialStateService->provideInitialState('isCirclesEnabled', $isCirclesEnabled && $isCircleVersionCompatible);
134157
$this->initialStateService->provideInitialState(
135158
'resource_booking_enabled',
136159
$enableResourceBooking,
137160
);
138-
$this->initialStateService->provideInitialState('has_notify_push', $this->queue !== null);
139161
}
140162

141163
/**

rspack.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ module.exports = defineConfig((env) => {
5858

5959
entry: {
6060
main: path.join(__dirname, 'src', 'main.js'),
61+
appMenu: path.join(__dirname, 'src', 'app-menu.ts'),
6162
reference: path.join(__dirname, 'src', 'reference.js'),
6263
'contacts-menu': path.join(__dirname, 'src', 'contactsMenu.js'),
6364
'appointments-booking': path.join(__dirname, 'src', 'appointments', 'main-booking.js'),

0 commit comments

Comments
 (0)