aboutsummaryrefslogtreecommitdiffstats
path: root/apps/user_status/lib
diff options
context:
space:
mode:
Diffstat (limited to 'apps/user_status/lib')
-rw-r--r--apps/user_status/lib/AppInfo/Application.php85
-rw-r--r--apps/user_status/lib/BackgroundJob/ClearOldStatusesBackgroundJob.php47
-rw-r--r--apps/user_status/lib/Capabilities.php38
-rw-r--r--apps/user_status/lib/Connector/UserStatus.php86
-rw-r--r--apps/user_status/lib/Connector/UserStatusProvider.php52
-rw-r--r--apps/user_status/lib/ContactsMenu/StatusProvider.php53
-rw-r--r--apps/user_status/lib/Controller/HeartbeatController.php94
-rw-r--r--apps/user_status/lib/Controller/PredefinedStatusController.php57
-rw-r--r--apps/user_status/lib/Controller/StatusesController.php104
-rw-r--r--apps/user_status/lib/Controller/UserStatusController.php209
-rw-r--r--apps/user_status/lib/Dashboard/UserStatusWidget.php177
-rw-r--r--apps/user_status/lib/Db/UserStatus.php86
-rw-r--r--apps/user_status/lib/Db/UserStatusMapper.php197
-rw-r--r--apps/user_status/lib/Exception/InvalidClearAtException.php12
-rw-r--r--apps/user_status/lib/Exception/InvalidMessageIdException.php12
-rw-r--r--apps/user_status/lib/Exception/InvalidStatusIconException.php12
-rw-r--r--apps/user_status/lib/Exception/InvalidStatusTypeException.php12
-rw-r--r--apps/user_status/lib/Exception/StatusMessageTooLongException.php12
-rw-r--r--apps/user_status/lib/Listener/BeforeTemplateRenderedListener.php75
-rw-r--r--apps/user_status/lib/Listener/OutOfOfficeStatusListener.php57
-rw-r--r--apps/user_status/lib/Listener/UserDeletedListener.php47
-rw-r--r--apps/user_status/lib/Listener/UserLiveStatusListener.php115
-rw-r--r--apps/user_status/lib/Migration/Version0001Date20200602134824.php80
-rw-r--r--apps/user_status/lib/Migration/Version0002Date20200902144824.php40
-rw-r--r--apps/user_status/lib/Migration/Version1000Date20201111130204.php44
-rw-r--r--apps/user_status/lib/Migration/Version1003Date20210809144824.php43
-rw-r--r--apps/user_status/lib/Migration/Version1008Date20230921144701.php54
-rw-r--r--apps/user_status/lib/ResponseDefinitions.php44
-rw-r--r--apps/user_status/lib/Service/JSDataService.php62
-rw-r--r--apps/user_status/lib/Service/PredefinedStatusService.php223
-rw-r--r--apps/user_status/lib/Service/StatusService.php599
31 files changed, 2828 insertions, 0 deletions
diff --git a/apps/user_status/lib/AppInfo/Application.php b/apps/user_status/lib/AppInfo/Application.php
new file mode 100644
index 00000000000..5199c3fdbf0
--- /dev/null
+++ b/apps/user_status/lib/AppInfo/Application.php
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\AppInfo;
+
+use OCA\UserStatus\Capabilities;
+use OCA\UserStatus\Connector\UserStatusProvider;
+use OCA\UserStatus\Dashboard\UserStatusWidget;
+use OCA\UserStatus\Listener\BeforeTemplateRenderedListener;
+use OCA\UserStatus\Listener\OutOfOfficeStatusListener;
+use OCA\UserStatus\Listener\UserDeletedListener;
+use OCA\UserStatus\Listener\UserLiveStatusListener;
+use OCP\AppFramework\App;
+use OCP\AppFramework\Bootstrap\IBootContext;
+use OCP\AppFramework\Bootstrap\IBootstrap;
+use OCP\AppFramework\Bootstrap\IRegistrationContext;
+use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
+use OCP\IConfig;
+use OCP\User\Events\OutOfOfficeChangedEvent;
+use OCP\User\Events\OutOfOfficeClearedEvent;
+use OCP\User\Events\OutOfOfficeEndedEvent;
+use OCP\User\Events\OutOfOfficeScheduledEvent;
+use OCP\User\Events\OutOfOfficeStartedEvent;
+use OCP\User\Events\UserDeletedEvent;
+use OCP\User\Events\UserLiveStatusEvent;
+use OCP\UserStatus\IManager;
+
+/**
+ * Class Application
+ *
+ * @package OCA\UserStatus\AppInfo
+ */
+class Application extends App implements IBootstrap {
+
+ /** @var string */
+ public const APP_ID = 'user_status';
+
+ /**
+ * Application constructor.
+ *
+ * @param array $urlParams
+ */
+ public function __construct(array $urlParams = []) {
+ parent::__construct(self::APP_ID, $urlParams);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function register(IRegistrationContext $context): void {
+ // Register OCS Capabilities
+ $context->registerCapability(Capabilities::class);
+
+ // Register Event Listeners
+ $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class);
+ $context->registerEventListener(UserLiveStatusEvent::class, UserLiveStatusListener::class);
+ $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class);
+ $context->registerEventListener(OutOfOfficeChangedEvent::class, OutOfOfficeStatusListener::class);
+ $context->registerEventListener(OutOfOfficeScheduledEvent::class, OutOfOfficeStatusListener::class);
+ $context->registerEventListener(OutOfOfficeClearedEvent::class, OutOfOfficeStatusListener::class);
+ $context->registerEventListener(OutOfOfficeStartedEvent::class, OutOfOfficeStatusListener::class);
+ $context->registerEventListener(OutOfOfficeEndedEvent::class, OutOfOfficeStatusListener::class);
+
+ $config = $this->getContainer()->query(IConfig::class);
+ $shareeEnumeration = $config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
+ $shareeEnumerationInGroupOnly = $shareeEnumeration && $config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
+ $shareeEnumerationPhone = $shareeEnumeration && $config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
+
+ // Register the Dashboard panel if user enumeration is enabled and not limited
+ if ($shareeEnumeration && !$shareeEnumerationInGroupOnly && !$shareeEnumerationPhone) {
+ $context->registerDashboardWidget(UserStatusWidget::class);
+ }
+ }
+
+ public function boot(IBootContext $context): void {
+ /** @var IManager $userStatusManager */
+ $userStatusManager = $context->getServerContainer()->get(IManager::class);
+ $userStatusManager->registerProvider(UserStatusProvider::class);
+ }
+}
diff --git a/apps/user_status/lib/BackgroundJob/ClearOldStatusesBackgroundJob.php b/apps/user_status/lib/BackgroundJob/ClearOldStatusesBackgroundJob.php
new file mode 100644
index 00000000000..51a9c623a03
--- /dev/null
+++ b/apps/user_status/lib/BackgroundJob/ClearOldStatusesBackgroundJob.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\BackgroundJob;
+
+use OCA\UserStatus\Db\UserStatusMapper;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+
+/**
+ * Class ClearOldStatusesBackgroundJob
+ *
+ * @package OCA\UserStatus\BackgroundJob
+ */
+class ClearOldStatusesBackgroundJob extends TimedJob {
+
+ /**
+ * ClearOldStatusesBackgroundJob constructor.
+ *
+ * @param ITimeFactory $time
+ * @param UserStatusMapper $mapper
+ */
+ public function __construct(
+ ITimeFactory $time,
+ private UserStatusMapper $mapper,
+ ) {
+ parent::__construct($time);
+
+ $this->setInterval(60);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function run($argument) {
+ $now = $this->time->getTime();
+
+ $this->mapper->clearOlderThanClearAt($now);
+ $this->mapper->clearStatusesOlderThan($now - StatusService::INVALIDATE_STATUS_THRESHOLD, $now);
+ }
+}
diff --git a/apps/user_status/lib/Capabilities.php b/apps/user_status/lib/Capabilities.php
new file mode 100644
index 00000000000..c3edbc032d6
--- /dev/null
+++ b/apps/user_status/lib/Capabilities.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus;
+
+use OCP\Capabilities\ICapability;
+use OCP\IEmojiHelper;
+
+/**
+ * Class Capabilities
+ *
+ * @package OCA\UserStatus
+ */
+class Capabilities implements ICapability {
+ public function __construct(
+ private IEmojiHelper $emojiHelper,
+ ) {
+ }
+
+ /**
+ * @return array{user_status: array{enabled: bool, restore: bool, supports_emoji: bool, supports_busy: bool}}
+ */
+ public function getCapabilities() {
+ return [
+ 'user_status' => [
+ 'enabled' => true,
+ 'restore' => true,
+ 'supports_emoji' => $this->emojiHelper->doesPlatformSupportEmoji(),
+ 'supports_busy' => true,
+ ],
+ ];
+ }
+}
diff --git a/apps/user_status/lib/Connector/UserStatus.php b/apps/user_status/lib/Connector/UserStatus.php
new file mode 100644
index 00000000000..04467a99e5e
--- /dev/null
+++ b/apps/user_status/lib/Connector/UserStatus.php
@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Connector;
+
+use DateTimeImmutable;
+use OCA\UserStatus\Db;
+use OCP\UserStatus\IUserStatus;
+
+class UserStatus implements IUserStatus {
+
+ /** @var string */
+ private $userId;
+
+ /** @var string */
+ private $status;
+
+ /** @var string|null */
+ private $message;
+
+ /** @var string|null */
+ private $icon;
+
+ /** @var DateTimeImmutable|null */
+ private $clearAt;
+
+ public function __construct(
+ private Db\UserStatus $internalStatus,
+ ) {
+ $this->userId = $this->internalStatus->getUserId();
+ $this->status = $this->internalStatus->getStatus();
+ $this->message = $this->internalStatus->getCustomMessage();
+ $this->icon = $this->internalStatus->getCustomIcon();
+
+ if ($this->internalStatus->getStatus() === IUserStatus::INVISIBLE) {
+ $this->status = IUserStatus::OFFLINE;
+ }
+ if ($this->internalStatus->getClearAt() !== null) {
+ $this->clearAt = DateTimeImmutable::createFromFormat('U', (string)$this->internalStatus->getClearAt());
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUserId(): string {
+ return $this->userId;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getStatus(): string {
+ return $this->status;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getMessage(): ?string {
+ return $this->message;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getIcon(): ?string {
+ return $this->icon;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getClearAt(): ?DateTimeImmutable {
+ return $this->clearAt;
+ }
+
+ public function getInternal(): Db\UserStatus {
+ return $this->internalStatus;
+ }
+}
diff --git a/apps/user_status/lib/Connector/UserStatusProvider.php b/apps/user_status/lib/Connector/UserStatusProvider.php
new file mode 100644
index 00000000000..e84d69d1eb2
--- /dev/null
+++ b/apps/user_status/lib/Connector/UserStatusProvider.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Connector;
+
+use OC\UserStatus\ISettableProvider;
+use OCA\UserStatus\Service\StatusService;
+use OCP\UserStatus\IProvider;
+
+class UserStatusProvider implements IProvider, ISettableProvider {
+
+ /**
+ * UserStatusProvider constructor.
+ *
+ * @param StatusService $service
+ */
+ public function __construct(
+ private StatusService $service,
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUserStatuses(array $userIds): array {
+ $statuses = $this->service->findByUserIds($userIds);
+
+ $userStatuses = [];
+ foreach ($statuses as $status) {
+ $userStatuses[$status->getUserId()] = new UserStatus($status);
+ }
+
+ return $userStatuses;
+ }
+
+ public function setUserStatus(string $userId, string $messageId, string $status, bool $createBackup, ?string $customMessage = null): void {
+ $this->service->setUserStatus($userId, $status, $messageId, $createBackup, $customMessage);
+ }
+
+ public function revertUserStatus(string $userId, string $messageId, string $status): void {
+ $this->service->revertUserStatus($userId, $messageId);
+ }
+
+ public function revertMultipleUserStatus(array $userIds, string $messageId, string $status): void {
+ $this->service->revertMultipleUserStatus($userIds, $messageId);
+ }
+}
diff --git a/apps/user_status/lib/ContactsMenu/StatusProvider.php b/apps/user_status/lib/ContactsMenu/StatusProvider.php
new file mode 100644
index 00000000000..6a6949b46ba
--- /dev/null
+++ b/apps/user_status/lib/ContactsMenu/StatusProvider.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UserStatus\ContactsMenu;
+
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Service\StatusService;
+use OCP\Contacts\ContactsMenu\IBulkProvider;
+use OCP\Contacts\ContactsMenu\IEntry;
+use function array_combine;
+use function array_filter;
+use function array_map;
+
+class StatusProvider implements IBulkProvider {
+
+ public function __construct(
+ private StatusService $statusService,
+ ) {
+ }
+
+ public function process(array $entries): void {
+ $uids = array_filter(
+ array_map(fn (IEntry $entry): ?string => $entry->getProperty('UID'), $entries)
+ );
+
+ $statuses = $this->statusService->findByUserIds($uids);
+ /** @var array<string, UserStatus> $indexed */
+ $indexed = array_combine(
+ array_map(fn (UserStatus $status) => $status->getUserId(), $statuses),
+ $statuses
+ );
+
+ foreach ($entries as $entry) {
+ $uid = $entry->getProperty('UID');
+ if ($uid !== null && isset($indexed[$uid])) {
+ $status = $indexed[$uid];
+ $entry->setStatus(
+ $status->getStatus(),
+ $status->getCustomMessage(),
+ $status->getStatusMessageTimestamp(),
+ $status->getCustomIcon(),
+ );
+ }
+ }
+ }
+
+}
diff --git a/apps/user_status/lib/Controller/HeartbeatController.php b/apps/user_status/lib/Controller/HeartbeatController.php
new file mode 100644
index 00000000000..30f4af6572a
--- /dev/null
+++ b/apps/user_status/lib/Controller/HeartbeatController.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Controller;
+
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\ResponseDefinitions;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\ApiRoute;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCSController;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IRequest;
+use OCP\IUserSession;
+use OCP\User\Events\UserLiveStatusEvent;
+use OCP\UserStatus\IUserStatus;
+
+/**
+ * @psalm-import-type UserStatusPrivate from ResponseDefinitions
+ */
+class HeartbeatController extends OCSController {
+
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private IEventDispatcher $eventDispatcher,
+ private IUserSession $userSession,
+ private ITimeFactory $timeFactory,
+ private StatusService $service,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Keep the status alive
+ *
+ * @param string $status Only online, away
+ *
+ * @return DataResponse<Http::STATUS_OK, UserStatusPrivate, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NO_CONTENT, list<empty>, array{}>
+ *
+ * 200: Status successfully updated
+ * 204: User has no status to keep alive
+ * 400: Invalid status to update
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'PUT', url: '/api/v1/heartbeat')]
+ public function heartbeat(string $status): DataResponse {
+ if (!\in_array($status, [IUserStatus::ONLINE, IUserStatus::AWAY], true)) {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return new DataResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
+ }
+
+ $event = new UserLiveStatusEvent(
+ $user,
+ $status,
+ $this->timeFactory->getTime()
+ );
+
+ $this->eventDispatcher->dispatchTyped($event);
+
+ $userStatus = $event->getUserStatus();
+ if (!$userStatus) {
+ return new DataResponse([], Http::STATUS_NO_CONTENT);
+ }
+
+ /** @psalm-suppress UndefinedInterfaceMethod */
+ return new DataResponse($this->formatStatus($userStatus->getInternal()));
+ }
+
+ private function formatStatus(UserStatus $status): array {
+ return [
+ 'userId' => $status->getUserId(),
+ 'message' => $status->getCustomMessage(),
+ 'messageId' => $status->getMessageId(),
+ 'messageIsPredefined' => $status->getMessageId() !== null,
+ 'icon' => $status->getCustomIcon(),
+ 'clearAt' => $status->getClearAt(),
+ 'status' => $status->getStatus(),
+ 'statusIsUserDefined' => $status->getIsUserDefined(),
+ ];
+ }
+}
diff --git a/apps/user_status/lib/Controller/PredefinedStatusController.php b/apps/user_status/lib/Controller/PredefinedStatusController.php
new file mode 100644
index 00000000000..70262d1108a
--- /dev/null
+++ b/apps/user_status/lib/Controller/PredefinedStatusController.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Controller;
+
+use OCA\UserStatus\ResponseDefinitions;
+use OCA\UserStatus\Service\PredefinedStatusService;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\ApiRoute;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCSController;
+use OCP\IRequest;
+
+/**
+ * @package OCA\UserStatus\Controller
+ *
+ * @psalm-import-type UserStatusPredefined from ResponseDefinitions
+ */
+class PredefinedStatusController extends OCSController {
+
+ /**
+ * AStatusController constructor.
+ *
+ * @param string $appName
+ * @param IRequest $request
+ * @param PredefinedStatusService $predefinedStatusService
+ */
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private PredefinedStatusService $predefinedStatusService,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Get all predefined messages
+ *
+ * @return DataResponse<Http::STATUS_OK, list<UserStatusPredefined>, array{}>
+ *
+ * 200: Predefined statuses returned
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'GET', url: '/api/v1/predefined_statuses/')]
+ public function findAll():DataResponse {
+ // Filtering out the invisible one, that should only be set by API
+ return new DataResponse(array_values(array_filter($this->predefinedStatusService->getDefaultStatuses(), function (array $status) {
+ return !array_key_exists('visible', $status) || $status['visible'] === true;
+ })));
+ }
+}
diff --git a/apps/user_status/lib/Controller/StatusesController.php b/apps/user_status/lib/Controller/StatusesController.php
new file mode 100644
index 00000000000..44688c39023
--- /dev/null
+++ b/apps/user_status/lib/Controller/StatusesController.php
@@ -0,0 +1,104 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Controller;
+
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\ResponseDefinitions;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\ApiRoute;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCS\OCSNotFoundException;
+use OCP\AppFramework\OCSController;
+use OCP\IRequest;
+use OCP\UserStatus\IUserStatus;
+
+/**
+ * @psalm-import-type UserStatusType from ResponseDefinitions
+ * @psalm-import-type UserStatusPublic from ResponseDefinitions
+ */
+class StatusesController extends OCSController {
+
+ /**
+ * StatusesController constructor.
+ *
+ * @param string $appName
+ * @param IRequest $request
+ * @param StatusService $service
+ */
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private StatusService $service,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Find statuses of users
+ *
+ * @param int|null $limit Maximum number of statuses to find
+ * @param non-negative-int|null $offset Offset for finding statuses
+ * @return DataResponse<Http::STATUS_OK, list<UserStatusPublic>, array{}>
+ *
+ * 200: Statuses returned
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'GET', url: '/api/v1/statuses')]
+ public function findAll(?int $limit = null, ?int $offset = null): DataResponse {
+ $allStatuses = $this->service->findAll($limit, $offset);
+
+ return new DataResponse(array_values(array_map(function ($userStatus) {
+ return $this->formatStatus($userStatus);
+ }, $allStatuses)));
+ }
+
+ /**
+ * Find the status of a user
+ *
+ * @param string $userId ID of the user
+ * @return DataResponse<Http::STATUS_OK, UserStatusPublic, array{}>
+ * @throws OCSNotFoundException The user was not found
+ *
+ * 200: Status returned
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'GET', url: '/api/v1/statuses/{userId}')]
+ public function find(string $userId): DataResponse {
+ try {
+ $userStatus = $this->service->findByUserId($userId);
+ } catch (DoesNotExistException $ex) {
+ throw new OCSNotFoundException('No status for the requested userId');
+ }
+
+ return new DataResponse($this->formatStatus($userStatus));
+ }
+
+ /**
+ * @param UserStatus $status
+ * @return UserStatusPublic
+ */
+ private function formatStatus(UserStatus $status): array {
+ /** @var UserStatusType $visibleStatus */
+ $visibleStatus = $status->getStatus();
+ if ($visibleStatus === IUserStatus::INVISIBLE) {
+ $visibleStatus = IUserStatus::OFFLINE;
+ }
+
+ return [
+ 'userId' => $status->getUserId(),
+ 'message' => $status->getCustomMessage(),
+ 'icon' => $status->getCustomIcon(),
+ 'clearAt' => $status->getClearAt(),
+ 'status' => $visibleStatus,
+ ];
+ }
+}
diff --git a/apps/user_status/lib/Controller/UserStatusController.php b/apps/user_status/lib/Controller/UserStatusController.php
new file mode 100644
index 00000000000..9b3807ce86e
--- /dev/null
+++ b/apps/user_status/lib/Controller/UserStatusController.php
@@ -0,0 +1,209 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Controller;
+
+use OCA\DAV\CalDAV\Status\StatusService as CalendarStatusService;
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Exception\InvalidClearAtException;
+use OCA\UserStatus\Exception\InvalidMessageIdException;
+use OCA\UserStatus\Exception\InvalidStatusIconException;
+use OCA\UserStatus\Exception\InvalidStatusTypeException;
+use OCA\UserStatus\Exception\StatusMessageTooLongException;
+use OCA\UserStatus\ResponseDefinitions;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\Attribute\ApiRoute;
+use OCP\AppFramework\Http\Attribute\NoAdminRequired;
+use OCP\AppFramework\Http\DataResponse;
+use OCP\AppFramework\OCS\OCSBadRequestException;
+use OCP\AppFramework\OCS\OCSNotFoundException;
+use OCP\AppFramework\OCSController;
+use OCP\IRequest;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @psalm-import-type UserStatusType from ResponseDefinitions
+ * @psalm-import-type UserStatusPrivate from ResponseDefinitions
+ */
+class UserStatusController extends OCSController {
+ public function __construct(
+ string $appName,
+ IRequest $request,
+ private ?string $userId,
+ private LoggerInterface $logger,
+ private StatusService $service,
+ private CalendarStatusService $calendarStatusService,
+ ) {
+ parent::__construct($appName, $request);
+ }
+
+ /**
+ * Get the status of the current user
+ *
+ * @return DataResponse<Http::STATUS_OK, UserStatusPrivate, array{}>
+ * @throws OCSNotFoundException The user was not found
+ *
+ * 200: The status was found successfully
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'GET', url: '/api/v1/user_status')]
+ public function getStatus(): DataResponse {
+ try {
+ $this->calendarStatusService->processCalendarStatus($this->userId);
+ $userStatus = $this->service->findByUserId($this->userId);
+ } catch (DoesNotExistException $ex) {
+ throw new OCSNotFoundException('No status for the current user');
+ }
+
+ return new DataResponse($this->formatStatus($userStatus));
+ }
+
+ /**
+ * Update the status type of the current user
+ *
+ * @param string $statusType The new status type
+ * @return DataResponse<Http::STATUS_OK, UserStatusPrivate, array{}>
+ * @throws OCSBadRequestException The status type is invalid
+ *
+ * 200: The status was updated successfully
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'PUT', url: '/api/v1/user_status/status')]
+ public function setStatus(string $statusType): DataResponse {
+ try {
+ $status = $this->service->setStatus($this->userId, $statusType, null, true);
+
+ $this->service->removeBackupUserStatus($this->userId);
+ return new DataResponse($this->formatStatus($status));
+ } catch (InvalidStatusTypeException $ex) {
+ $this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to an invalid status type "' . $statusType . '"');
+ throw new OCSBadRequestException($ex->getMessage(), $ex);
+ }
+ }
+
+ /**
+ * Set the message to a predefined message for the current user
+ *
+ * @param string $messageId ID of the predefined message
+ * @param int|null $clearAt When the message should be cleared
+ * @return DataResponse<Http::STATUS_OK, UserStatusPrivate, array{}>
+ * @throws OCSBadRequestException The clearAt or message-id is invalid
+ *
+ * 200: The message was updated successfully
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'PUT', url: '/api/v1/user_status/message/predefined')]
+ public function setPredefinedMessage(string $messageId,
+ ?int $clearAt): DataResponse {
+ try {
+ $status = $this->service->setPredefinedMessage($this->userId, $messageId, $clearAt);
+ $this->service->removeBackupUserStatus($this->userId);
+ return new DataResponse($this->formatStatus($status));
+ } catch (InvalidClearAtException $ex) {
+ $this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to an invalid clearAt value "' . $clearAt . '"');
+ throw new OCSBadRequestException($ex->getMessage(), $ex);
+ } catch (InvalidMessageIdException $ex) {
+ $this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to an invalid message-id "' . $messageId . '"');
+ throw new OCSBadRequestException($ex->getMessage(), $ex);
+ }
+ }
+
+ /**
+ * Set the message to a custom message for the current user
+ *
+ * @param string|null $statusIcon Icon of the status
+ * @param string|null $message Message of the status
+ * @param int|null $clearAt When the message should be cleared
+ * @return DataResponse<Http::STATUS_OK, UserStatusPrivate, array{}>
+ * @throws OCSBadRequestException The clearAt or icon is invalid or the message is too long
+ * @throws OCSNotFoundException No status for the current user
+ *
+ * 200: The message was updated successfully
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'PUT', url: '/api/v1/user_status/message/custom')]
+ public function setCustomMessage(?string $statusIcon,
+ ?string $message,
+ ?int $clearAt): DataResponse {
+ try {
+ if (($statusIcon !== null && $statusIcon !== '') || ($message !== null && $message !== '') || ($clearAt !== null && $clearAt !== 0)) {
+ $status = $this->service->setCustomMessage($this->userId, $statusIcon, $message, $clearAt);
+ } else {
+ $this->service->clearMessage($this->userId);
+ $status = $this->service->findByUserId($this->userId);
+ }
+ $this->service->removeBackupUserStatus($this->userId);
+ return new DataResponse($this->formatStatus($status));
+ } catch (InvalidClearAtException $ex) {
+ $this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to an invalid clearAt value "' . $clearAt . '"');
+ throw new OCSBadRequestException($ex->getMessage(), $ex);
+ } catch (InvalidStatusIconException $ex) {
+ $this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to an invalid icon value "' . $statusIcon . '"');
+ throw new OCSBadRequestException($ex->getMessage(), $ex);
+ } catch (StatusMessageTooLongException $ex) {
+ $this->logger->debug('New user-status for "' . $this->userId . '" was rejected due to a too long status message.');
+ throw new OCSBadRequestException($ex->getMessage(), $ex);
+ } catch (DoesNotExistException $ex) {
+ throw new OCSNotFoundException('No status for the current user');
+ }
+ }
+
+ /**
+ * Clear the message of the current user
+ *
+ * @return DataResponse<Http::STATUS_OK, list<empty>, array{}>
+ *
+ * 200: Message cleared successfully
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'DELETE', url: '/api/v1/user_status/message')]
+ public function clearMessage(): DataResponse {
+ $this->service->clearMessage($this->userId);
+ return new DataResponse([]);
+ }
+
+ /**
+ * Revert the status to the previous status
+ *
+ * @param string $messageId ID of the message to delete
+ *
+ * @return DataResponse<Http::STATUS_OK, UserStatusPrivate|list<empty>, array{}>
+ *
+ * 200: Status reverted
+ */
+ #[NoAdminRequired]
+ #[ApiRoute(verb: 'DELETE', url: '/api/v1/user_status/revert/{messageId}')]
+ public function revertStatus(string $messageId): DataResponse {
+ $backupStatus = $this->service->revertUserStatus($this->userId, $messageId, true);
+ if ($backupStatus) {
+ return new DataResponse($this->formatStatus($backupStatus));
+ }
+ return new DataResponse([]);
+ }
+
+ /**
+ * @param UserStatus $status
+ * @return UserStatusPrivate
+ */
+ private function formatStatus(UserStatus $status): array {
+ /** @var UserStatusType $visibleStatus */
+ $visibleStatus = $status->getStatus();
+ return [
+ 'userId' => $status->getUserId(),
+ 'message' => $status->getCustomMessage(),
+ 'messageId' => $status->getMessageId(),
+ 'messageIsPredefined' => $status->getMessageId() !== null,
+ 'icon' => $status->getCustomIcon(),
+ 'clearAt' => $status->getClearAt(),
+ 'status' => $visibleStatus,
+ 'statusIsUserDefined' => $status->getIsUserDefined(),
+ ];
+ }
+}
diff --git a/apps/user_status/lib/Dashboard/UserStatusWidget.php b/apps/user_status/lib/Dashboard/UserStatusWidget.php
new file mode 100644
index 00000000000..2870a2c1907
--- /dev/null
+++ b/apps/user_status/lib/Dashboard/UserStatusWidget.php
@@ -0,0 +1,177 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Dashboard;
+
+use OCA\UserStatus\AppInfo\Application;
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Services\IInitialState;
+use OCP\Dashboard\IAPIWidget;
+use OCP\Dashboard\IAPIWidgetV2;
+use OCP\Dashboard\IIconWidget;
+use OCP\Dashboard\IOptionWidget;
+use OCP\Dashboard\Model\WidgetItem;
+use OCP\Dashboard\Model\WidgetItems;
+use OCP\Dashboard\Model\WidgetOptions;
+use OCP\IDateTimeFormatter;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\UserStatus\IUserStatus;
+
+/**
+ * Class UserStatusWidget
+ *
+ * @package OCA\UserStatus
+ */
+class UserStatusWidget implements IAPIWidget, IAPIWidgetV2, IIconWidget, IOptionWidget {
+ /**
+ * UserStatusWidget constructor
+ *
+ * @param IL10N $l10n
+ * @param IDateTimeFormatter $dateTimeFormatter
+ * @param IURLGenerator $urlGenerator
+ * @param IInitialState $initialStateService
+ * @param IUserManager $userManager
+ * @param IUserSession $userSession
+ * @param StatusService $service
+ */
+ public function __construct(
+ private IL10N $l10n,
+ private IDateTimeFormatter $dateTimeFormatter,
+ private IURLGenerator $urlGenerator,
+ private IInitialState $initialStateService,
+ private IUserManager $userManager,
+ private IUserSession $userSession,
+ private StatusService $service,
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getId(): string {
+ return Application::APP_ID;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getTitle(): string {
+ return $this->l10n->t('Recent statuses');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getOrder(): int {
+ return 5;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getIconClass(): string {
+ return 'icon-user-status-dark';
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getIconUrl(): string {
+ return $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg')
+ );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUrl(): ?string {
+ return null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function load(): void {
+ }
+
+ private function getWidgetData(string $userId, ?string $since = null, int $limit = 7): array {
+ // Fetch status updates and filter current user
+ $recentStatusUpdates = array_slice(
+ array_filter(
+ $this->service->findAllRecentStatusChanges($limit + 1, 0),
+ static function (UserStatus $status) use ($userId, $since): bool {
+ return $status->getUserId() !== $userId
+ && ($since === null || $status->getStatusTimestamp() > (int)$since);
+ }
+ ),
+ 0,
+ $limit
+ );
+ return array_map(function (UserStatus $status): array {
+ $user = $this->userManager->get($status->getUserId());
+ $displayName = $status->getUserId();
+ if ($user !== null) {
+ $displayName = $user->getDisplayName();
+ }
+
+ return [
+ 'userId' => $status->getUserId(),
+ 'displayName' => $displayName,
+ 'status' => $status->getStatus() === IUserStatus::INVISIBLE
+ ? IUserStatus::OFFLINE
+ : $status->getStatus(),
+ 'icon' => $status->getCustomIcon(),
+ 'message' => $status->getCustomMessage(),
+ 'timestamp' => $status->getStatusMessageTimestamp(),
+ ];
+ }, $recentStatusUpdates);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getItems(string $userId, ?string $since = null, int $limit = 7): array {
+ $widgetItemsData = $this->getWidgetData($userId, $since, $limit);
+
+ return array_values(array_map(function (array $widgetData) {
+ $formattedDate = $this->dateTimeFormatter->formatTimeSpan($widgetData['timestamp']);
+ return new WidgetItem(
+ $widgetData['displayName'],
+ $widgetData['icon'] . ($widgetData['icon'] ? ' ' : '') . $widgetData['message'] . ', ' . $formattedDate,
+ // https://nextcloud.local/index.php/u/julien
+ $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->linkToRoute('profile.ProfilePage.index', ['targetUserId' => $widgetData['userId']])
+ ),
+ $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->linkToRoute('core.avatar.getAvatar', ['userId' => $widgetData['userId'], 'size' => 44])
+ ),
+ (string)$widgetData['timestamp']
+ );
+ }, $widgetItemsData));
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
+ $items = $this->getItems($userId, $since, $limit);
+ return new WidgetItems(
+ $items,
+ count($items) === 0 ? $this->l10n->t('No recent status changes') : '',
+ );
+ }
+
+ public function getWidgetOptions(): WidgetOptions {
+ return new WidgetOptions(true);
+ }
+}
diff --git a/apps/user_status/lib/Db/UserStatus.php b/apps/user_status/lib/Db/UserStatus.php
new file mode 100644
index 00000000000..b2da4a9e07a
--- /dev/null
+++ b/apps/user_status/lib/Db/UserStatus.php
@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Db;
+
+use OCP\AppFramework\Db\Entity;
+use OCP\DB\Types;
+
+/**
+ * Class UserStatus
+ *
+ * @package OCA\UserStatus\Db
+ *
+ * @method int getId()
+ * @method void setId(int $id)
+ * @method string getUserId()
+ * @method void setUserId(string $userId)
+ * @method string getStatus()
+ * @method void setStatus(string $status)
+ * @method int getStatusTimestamp()
+ * @method void setStatusTimestamp(int $statusTimestamp)
+ * @method bool getIsUserDefined()
+ * @method void setIsUserDefined(bool $isUserDefined)
+ * @method string|null getMessageId()
+ * @method void setMessageId(string|null $messageId)
+ * @method string|null getCustomIcon()
+ * @method void setCustomIcon(string|null $customIcon)
+ * @method string|null getCustomMessage()
+ * @method void setCustomMessage(string|null $customMessage)
+ * @method int|null getClearAt()
+ * @method void setClearAt(int|null $clearAt)
+ * @method setIsBackup(bool $isBackup): void
+ * @method getIsBackup(): bool
+ * @method int getStatusMessageTimestamp()
+ * @method void setStatusMessageTimestamp(int $statusTimestamp)
+ */
+class UserStatus extends Entity {
+
+ /** @var string */
+ public $userId;
+
+ /** @var string */
+ public $status;
+
+ /** @var int */
+ public $statusTimestamp;
+
+ /** @var boolean */
+ public $isUserDefined;
+
+ /** @var string|null */
+ public $messageId;
+
+ /** @var string|null */
+ public $customIcon;
+
+ /** @var string|null */
+ public $customMessage;
+
+ /** @var int|null */
+ public $clearAt;
+
+ /** @var bool $isBackup */
+ public $isBackup;
+
+ /** @var int */
+ protected $statusMessageTimestamp = 0;
+
+ public function __construct() {
+ $this->addType('userId', 'string');
+ $this->addType('status', 'string');
+ $this->addType('statusTimestamp', Types::INTEGER);
+ $this->addType('isUserDefined', 'boolean');
+ $this->addType('messageId', 'string');
+ $this->addType('customIcon', 'string');
+ $this->addType('customMessage', 'string');
+ $this->addType('clearAt', Types::INTEGER);
+ $this->addType('isBackup', 'boolean');
+ $this->addType('statusMessageTimestamp', Types::INTEGER);
+ }
+}
diff --git a/apps/user_status/lib/Db/UserStatusMapper.php b/apps/user_status/lib/Db/UserStatusMapper.php
new file mode 100644
index 00000000000..15982d44fd8
--- /dev/null
+++ b/apps/user_status/lib/Db/UserStatusMapper.php
@@ -0,0 +1,197 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UserStatus\Db;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Db\QBMapper;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+use OCP\UserStatus\IUserStatus;
+
+/**
+ * @template-extends QBMapper<UserStatus>
+ */
+class UserStatusMapper extends QBMapper {
+
+ /**
+ * @param IDBConnection $db
+ */
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'user_status');
+ }
+
+ /**
+ * @param int|null $limit
+ * @param int|null $offset
+ * @return UserStatus[]
+ */
+ public function findAll(?int $limit = null, ?int $offset = null):array {
+ $qb = $this->db->getQueryBuilder();
+ $qb
+ ->select('*')
+ ->from($this->tableName);
+
+ if ($limit !== null) {
+ $qb->setMaxResults($limit);
+ }
+ if ($offset !== null) {
+ $qb->setFirstResult($offset);
+ }
+
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * @param int|null $limit
+ * @param int|null $offset
+ * @return array
+ */
+ public function findAllRecent(?int $limit = null, ?int $offset = null): array {
+ $qb = $this->db->getQueryBuilder();
+
+ $qb
+ ->select('*')
+ ->from($this->tableName)
+ ->orderBy('status_message_timestamp', 'DESC')
+ ->where($qb->expr()->andX(
+ $qb->expr()->neq('status_message_timestamp', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
+ $qb->expr()->orX(
+ $qb->expr()->notIn('status', $qb->createNamedParameter([IUserStatus::ONLINE, IUserStatus::AWAY, IUserStatus::OFFLINE], IQueryBuilder::PARAM_STR_ARRAY)),
+ $qb->expr()->isNotNull('message_id'),
+ $qb->expr()->isNotNull('custom_icon'),
+ $qb->expr()->isNotNull('custom_message'),
+ ),
+ $qb->expr()->notLike('user_id', $qb->createNamedParameter($this->db->escapeLikeParameter('_') . '%'))
+ ));
+
+ if ($limit !== null) {
+ $qb->setMaxResults($limit);
+ }
+ if ($offset !== null) {
+ $qb->setFirstResult($offset);
+ }
+
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * @param string $userId
+ * @return UserStatus
+ * @throws DoesNotExistException
+ */
+ public function findByUserId(string $userId, bool $isBackup = false): UserStatus {
+ $qb = $this->db->getQueryBuilder();
+ $qb
+ ->select('*')
+ ->from($this->tableName)
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($isBackup ? '_' . $userId : $userId, IQueryBuilder::PARAM_STR)));
+
+ return $this->findEntity($qb);
+ }
+
+ /**
+ * @param array $userIds
+ * @return array
+ */
+ public function findByUserIds(array $userIds): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb
+ ->select('*')
+ ->from($this->tableName)
+ ->where($qb->expr()->in('user_id', $qb->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY)));
+
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * @param int $olderThan
+ * @param int $now
+ */
+ public function clearStatusesOlderThan(int $olderThan, int $now): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->update($this->tableName)
+ ->set('status', $qb->createNamedParameter(IUserStatus::OFFLINE))
+ ->set('is_user_defined', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
+ ->set('status_timestamp', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))
+ ->where($qb->expr()->lte('status_timestamp', $qb->createNamedParameter($olderThan, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->neq('status', $qb->createNamedParameter(IUserStatus::OFFLINE)))
+ ->andWhere($qb->expr()->orX(
+ $qb->expr()->eq('is_user_defined', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL), IQueryBuilder::PARAM_BOOL),
+ $qb->expr()->eq('status', $qb->createNamedParameter(IUserStatus::ONLINE))
+ ));
+
+ $qb->executeStatement();
+ }
+
+ /**
+ * Clear all statuses older than a given timestamp
+ *
+ * @param int $timestamp
+ */
+ public function clearOlderThanClearAt(int $timestamp): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->tableName)
+ ->where($qb->expr()->isNotNull('clear_at'))
+ ->andWhere($qb->expr()->lte('clear_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)));
+
+ $qb->executeStatement();
+ }
+
+
+ /**
+ * Deletes a user status so we can restore the backup
+ *
+ * @param string $userId
+ * @param string $messageId
+ * @return bool True if an entry was deleted
+ */
+ public function deleteCurrentStatusToRestoreBackup(string $userId, string $messageId): bool {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->tableName)
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)))
+ ->andWhere($qb->expr()->eq('message_id', $qb->createNamedParameter($messageId)))
+ ->andWhere($qb->expr()->eq('is_backup', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)));
+ return $qb->executeStatement() > 0;
+ }
+
+ public function deleteByIds(array $ids): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->tableName)
+ ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)));
+ $qb->executeStatement();
+ }
+
+ /**
+ * @param string $userId
+ * @return bool
+ * @throws \OCP\DB\Exception
+ */
+ public function createBackupStatus(string $userId): bool {
+ // Prefix user account with an underscore because user_id is marked as unique
+ // in the table. Starting a username with an underscore is not allowed so this
+ // shouldn't create any trouble.
+ $qb = $this->db->getQueryBuilder();
+ $qb->update($this->tableName)
+ ->set('is_backup', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))
+ ->set('user_id', $qb->createNamedParameter('_' . $userId))
+ ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
+ return $qb->executeStatement() > 0;
+ }
+
+ public function restoreBackupStatuses(array $ids): void {
+ $qb = $this->db->getQueryBuilder();
+ $qb->update($this->tableName)
+ ->set('is_backup', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
+ ->set('user_id', $qb->func()->substring('user_id', $qb->createNamedParameter(2, IQueryBuilder::PARAM_INT)))
+ ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)));
+
+ $qb->executeStatement();
+ }
+}
diff --git a/apps/user_status/lib/Exception/InvalidClearAtException.php b/apps/user_status/lib/Exception/InvalidClearAtException.php
new file mode 100644
index 00000000000..a3bd4dfa0d0
--- /dev/null
+++ b/apps/user_status/lib/Exception/InvalidClearAtException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Exception;
+
+class InvalidClearAtException extends \Exception {
+}
diff --git a/apps/user_status/lib/Exception/InvalidMessageIdException.php b/apps/user_status/lib/Exception/InvalidMessageIdException.php
new file mode 100644
index 00000000000..1feb36a916a
--- /dev/null
+++ b/apps/user_status/lib/Exception/InvalidMessageIdException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Exception;
+
+class InvalidMessageIdException extends \Exception {
+}
diff --git a/apps/user_status/lib/Exception/InvalidStatusIconException.php b/apps/user_status/lib/Exception/InvalidStatusIconException.php
new file mode 100644
index 00000000000..80dff2a7666
--- /dev/null
+++ b/apps/user_status/lib/Exception/InvalidStatusIconException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Exception;
+
+class InvalidStatusIconException extends \Exception {
+}
diff --git a/apps/user_status/lib/Exception/InvalidStatusTypeException.php b/apps/user_status/lib/Exception/InvalidStatusTypeException.php
new file mode 100644
index 00000000000..a09284be40e
--- /dev/null
+++ b/apps/user_status/lib/Exception/InvalidStatusTypeException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Exception;
+
+class InvalidStatusTypeException extends \Exception {
+}
diff --git a/apps/user_status/lib/Exception/StatusMessageTooLongException.php b/apps/user_status/lib/Exception/StatusMessageTooLongException.php
new file mode 100644
index 00000000000..03d578abf46
--- /dev/null
+++ b/apps/user_status/lib/Exception/StatusMessageTooLongException.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Exception;
+
+class StatusMessageTooLongException extends \Exception {
+}
diff --git a/apps/user_status/lib/Listener/BeforeTemplateRenderedListener.php b/apps/user_status/lib/Listener/BeforeTemplateRenderedListener.php
new file mode 100644
index 00000000000..ab3a1e62beb
--- /dev/null
+++ b/apps/user_status/lib/Listener/BeforeTemplateRenderedListener.php
@@ -0,0 +1,75 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UserStatus\Listener;
+
+use OC\Profile\ProfileManager;
+use OCA\UserStatus\AppInfo\Application;
+use OCA\UserStatus\Service\JSDataService;
+use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent;
+use OCP\AppFramework\Http\TemplateResponse;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\IInitialStateService;
+use OCP\IUserSession;
+use OCP\Util;
+
+/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */
+class BeforeTemplateRenderedListener implements IEventListener {
+
+ /** @var ProfileManager */
+ private $profileManager;
+
+ /**
+ * BeforeTemplateRenderedListener constructor.
+ *
+ * @param ProfileManager $profileManager
+ * @param IUserSession $userSession
+ * @param IInitialStateService $initialState
+ * @param JSDataService $jsDataService
+ */
+ public function __construct(
+ ProfileManager $profileManager,
+ private IUserSession $userSession,
+ private IInitialStateService $initialState,
+ private JSDataService $jsDataService,
+ ) {
+ $this->profileManager = $profileManager;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function handle(Event $event): void {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return;
+ }
+
+ if (!($event instanceof BeforeTemplateRenderedEvent)) {
+ // Unrelated
+ return;
+ }
+
+ if (!$event->isLoggedIn() || $event->getResponse()->getRenderAs() !== TemplateResponse::RENDER_AS_USER) {
+ return;
+ }
+
+ $this->initialState->provideLazyInitialState(Application::APP_ID, 'status', function () {
+ return $this->jsDataService;
+ });
+
+ $this->initialState->provideLazyInitialState(Application::APP_ID, 'profileEnabled', function () use ($user) {
+ return ['profileEnabled' => $this->profileManager->isProfileEnabled($user)];
+ });
+
+ Util::addScript('user_status', 'menu');
+ Util::addStyle('user_status', 'user-status-menu');
+ }
+}
diff --git a/apps/user_status/lib/Listener/OutOfOfficeStatusListener.php b/apps/user_status/lib/Listener/OutOfOfficeStatusListener.php
new file mode 100644
index 00000000000..6337d637896
--- /dev/null
+++ b/apps/user_status/lib/Listener/OutOfOfficeStatusListener.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Listener;
+
+use OCA\DAV\BackgroundJob\UserStatusAutomation;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\IJobList;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\User\Events\OutOfOfficeChangedEvent;
+use OCP\User\Events\OutOfOfficeClearedEvent;
+use OCP\User\Events\OutOfOfficeEndedEvent;
+use OCP\User\Events\OutOfOfficeScheduledEvent;
+use OCP\User\Events\OutOfOfficeStartedEvent;
+use OCP\UserStatus\IManager;
+use OCP\UserStatus\IUserStatus;
+
+/**
+ * Class UserDeletedListener
+ *
+ * @template-implements IEventListener<OutOfOfficeScheduledEvent|OutOfOfficeChangedEvent|OutOfOfficeClearedEvent|OutOfOfficeStartedEvent|OutOfOfficeEndedEvent>
+ *
+ */
+class OutOfOfficeStatusListener implements IEventListener {
+ public function __construct(
+ private IJobList $jobsList,
+ private ITimeFactory $time,
+ private IManager $manager,
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function handle(Event $event): void {
+ if ($event instanceof OutOfOfficeClearedEvent) {
+ $this->manager->revertUserStatus($event->getData()->getUser()->getUID(), IUserStatus::MESSAGE_OUT_OF_OFFICE, IUserStatus::DND);
+ $this->jobsList->scheduleAfter(UserStatusAutomation::class, $this->time->getTime(), ['userId' => $event->getData()->getUser()->getUID()]);
+ return;
+ }
+
+ if ($event instanceof OutOfOfficeScheduledEvent
+ || $event instanceof OutOfOfficeChangedEvent
+ || $event instanceof OutOfOfficeStartedEvent
+ || $event instanceof OutOfOfficeEndedEvent
+ ) {
+ // This might be overwritten by the office hours automation, but that is ok. This is just in case no office hours are set
+ $this->jobsList->scheduleAfter(UserStatusAutomation::class, $this->time->getTime(), ['userId' => $event->getData()->getUser()->getUID()]);
+ }
+ }
+}
diff --git a/apps/user_status/lib/Listener/UserDeletedListener.php b/apps/user_status/lib/Listener/UserDeletedListener.php
new file mode 100644
index 00000000000..bf021635156
--- /dev/null
+++ b/apps/user_status/lib/Listener/UserDeletedListener.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Listener;
+
+use OCA\UserStatus\Service\StatusService;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\User\Events\UserDeletedEvent;
+
+/**
+ * Class UserDeletedListener
+ *
+ * @package OCA\UserStatus\Listener
+ * @template-implements IEventListener<UserDeletedEvent>
+ */
+class UserDeletedListener implements IEventListener {
+
+ /**
+ * UserDeletedListener constructor.
+ *
+ * @param StatusService $service
+ */
+ public function __construct(
+ private StatusService $service,
+ ) {
+ }
+
+
+ /**
+ * @inheritDoc
+ */
+ public function handle(Event $event): void {
+ if (!($event instanceof UserDeletedEvent)) {
+ // Unrelated
+ return;
+ }
+
+ $user = $event->getUser();
+ $this->service->removeUserStatus($user->getUID());
+ }
+}
diff --git a/apps/user_status/lib/Listener/UserLiveStatusListener.php b/apps/user_status/lib/Listener/UserLiveStatusListener.php
new file mode 100644
index 00000000000..2db999d3712
--- /dev/null
+++ b/apps/user_status/lib/Listener/UserLiveStatusListener.php
@@ -0,0 +1,115 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Listener;
+
+use OCA\DAV\CalDAV\Status\StatusService as CalendarStatusService;
+use OCA\UserStatus\Connector\UserStatus as ConnectorUserStatus;
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Db\UserStatusMapper;
+use OCA\UserStatus\Service\StatusService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\DB\Exception;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\User\Events\UserLiveStatusEvent;
+use OCP\UserStatus\IUserStatus;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Class UserDeletedListener
+ *
+ * @package OCA\UserStatus\Listener
+ * @template-implements IEventListener<UserLiveStatusEvent>
+ */
+class UserLiveStatusListener implements IEventListener {
+ public function __construct(
+ private UserStatusMapper $mapper,
+ private StatusService $statusService,
+ private ITimeFactory $timeFactory,
+ private CalendarStatusService $calendarStatusService,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function handle(Event $event): void {
+ if (!($event instanceof UserLiveStatusEvent)) {
+ // Unrelated
+ return;
+ }
+
+ $user = $event->getUser();
+ try {
+ $this->calendarStatusService->processCalendarStatus($user->getUID());
+ $userStatus = $this->statusService->findByUserId($user->getUID());
+ } catch (DoesNotExistException $ex) {
+ $userStatus = new UserStatus();
+ $userStatus->setUserId($user->getUID());
+ $userStatus->setStatus(IUserStatus::OFFLINE);
+ $userStatus->setStatusTimestamp(0);
+ $userStatus->setIsUserDefined(false);
+ }
+
+ // If the status is user-defined and one of the persistent status, we
+ // will not override it.
+ if ($userStatus->getIsUserDefined()
+ && \in_array($userStatus->getStatus(), StatusService::PERSISTENT_STATUSES, true)) {
+ return;
+ }
+
+ // Don't overwrite the "away" calendar status if it's set
+ if ($userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY) {
+ $event->setUserStatus(new ConnectorUserStatus($userStatus));
+ return;
+ }
+
+ $needsUpdate = false;
+
+ // If the current status is older than 5 minutes,
+ // treat it as outdated and update
+ if ($userStatus->getStatusTimestamp() < ($this->timeFactory->getTime() - StatusService::INVALIDATE_STATUS_THRESHOLD)) {
+ $needsUpdate = true;
+ }
+
+ // If the emitted status is more important than the current status
+ // treat it as outdated and update
+ if (array_search($event->getStatus(), StatusService::PRIORITY_ORDERED_STATUSES) < array_search($userStatus->getStatus(), StatusService::PRIORITY_ORDERED_STATUSES)) {
+ $needsUpdate = true;
+ }
+
+ if ($needsUpdate) {
+ $userStatus->setStatus($event->getStatus());
+ $userStatus->setStatusTimestamp($event->getTimestamp());
+ $userStatus->setIsUserDefined(false);
+
+ if ($userStatus->getId() === null) {
+ try {
+ $this->mapper->insert($userStatus);
+ } catch (Exception $e) {
+ if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
+ // A different process might have written another status
+ // update to the DB while we're processing our stuff.
+ // We can safely ignore it as we're only changing between AWAY and ONLINE
+ // and not doing anything with the message or icon.
+ $this->logger->debug('Unique constraint violation for live user status', ['exception' => $e]);
+ return;
+ }
+ throw $e;
+ }
+ } else {
+ $this->mapper->update($userStatus);
+ }
+ }
+
+ $event->setUserStatus(new ConnectorUserStatus($userStatus));
+ }
+}
diff --git a/apps/user_status/lib/Migration/Version0001Date20200602134824.php b/apps/user_status/lib/Migration/Version0001Date20200602134824.php
new file mode 100644
index 00000000000..678c2ec245a
--- /dev/null
+++ b/apps/user_status/lib/Migration/Version0001Date20200602134824.php
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Class Version0001Date20200602134824
+ *
+ * @package OCA\UserStatus\Migration
+ */
+class Version0001Date20200602134824 extends SimpleMigrationStep {
+
+ /**
+ * @param IOutput $output
+ * @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
+ * @param array $options
+ * @return null|ISchemaWrapper
+ * @since 20.0.0
+ */
+ public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options) {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $statusTable = $schema->createTable('user_status');
+ $statusTable->addColumn('id', Types::BIGINT, [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 20,
+ 'unsigned' => true,
+ ]);
+ $statusTable->addColumn('user_id', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ ]);
+ $statusTable->addColumn('status', Types::STRING, [
+ 'notnull' => true,
+ 'length' => 255,
+ ]);
+ $statusTable->addColumn('status_timestamp', Types::INTEGER, [
+ 'notnull' => true,
+ 'length' => 11,
+ 'unsigned' => true,
+ ]);
+ $statusTable->addColumn('is_user_defined', Types::BOOLEAN, [
+ 'notnull' => false,
+ ]);
+ $statusTable->addColumn('message_id', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 255,
+ ]);
+ $statusTable->addColumn('custom_icon', Types::STRING, [
+ 'notnull' => false,
+ 'length' => 255,
+ ]);
+ $statusTable->addColumn('custom_message', Types::TEXT, [
+ 'notnull' => false,
+ ]);
+ $statusTable->addColumn('clear_at', Types::INTEGER, [
+ 'notnull' => false,
+ 'length' => 11,
+ 'unsigned' => true,
+ ]);
+
+ $statusTable->setPrimaryKey(['id']);
+ $statusTable->addUniqueIndex(['user_id'], 'user_status_uid_ix');
+ $statusTable->addIndex(['clear_at'], 'user_status_clr_ix');
+
+ return $schema;
+ }
+}
diff --git a/apps/user_status/lib/Migration/Version0002Date20200902144824.php b/apps/user_status/lib/Migration/Version0002Date20200902144824.php
new file mode 100644
index 00000000000..199d2a4cc6b
--- /dev/null
+++ b/apps/user_status/lib/Migration/Version0002Date20200902144824.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Class Version0001Date20200602134824
+ *
+ * @package OCA\UserStatus\Migration
+ */
+class Version0002Date20200902144824 extends SimpleMigrationStep {
+
+ /**
+ * @param IOutput $output
+ * @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
+ * @param array $options
+ * @return null|ISchemaWrapper
+ * @since 20.0.0
+ */
+ public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options) {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $statusTable = $schema->getTable('user_status');
+
+ $statusTable->addIndex(['status_timestamp'], 'user_status_tstmp_ix');
+ $statusTable->addIndex(['is_user_defined', 'status'], 'user_status_iud_ix');
+
+ return $schema;
+ }
+}
diff --git a/apps/user_status/lib/Migration/Version1000Date20201111130204.php b/apps/user_status/lib/Migration/Version1000Date20201111130204.php
new file mode 100644
index 00000000000..b0789684da0
--- /dev/null
+++ b/apps/user_status/lib/Migration/Version1000Date20201111130204.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+class Version1000Date20201111130204 extends SimpleMigrationStep {
+
+ /**
+ * @param IOutput $output
+ * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
+ * @param array $options
+ * @return null|ISchemaWrapper
+ */
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $result = $this->ensureColumnIsNullable($schema, 'user_status', 'is_user_defined');
+
+ return $result ? $schema : null;
+ }
+
+ protected function ensureColumnIsNullable(ISchemaWrapper $schema, string $tableName, string $columnName): bool {
+ $table = $schema->getTable($tableName);
+ $column = $table->getColumn($columnName);
+
+ if ($column->getNotnull()) {
+ $column->setNotnull(false);
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/apps/user_status/lib/Migration/Version1003Date20210809144824.php b/apps/user_status/lib/Migration/Version1003Date20210809144824.php
new file mode 100644
index 00000000000..7c6cf76adbe
--- /dev/null
+++ b/apps/user_status/lib/Migration/Version1003Date20210809144824.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Migration;
+
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * @package OCA\UserStatus\Migration
+ */
+class Version1003Date20210809144824 extends SimpleMigrationStep {
+
+ /**
+ * @param IOutput $output
+ * @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
+ * @param array $options
+ * @return null|ISchemaWrapper
+ * @since 23.0.0
+ */
+ public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options) {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $statusTable = $schema->getTable('user_status');
+
+ if (!$statusTable->hasColumn('is_backup')) {
+ $statusTable->addColumn('is_backup', Types::BOOLEAN, [
+ 'notnull' => false,
+ 'default' => false,
+ ]);
+ }
+
+ return $schema;
+ }
+}
diff --git a/apps/user_status/lib/Migration/Version1008Date20230921144701.php b/apps/user_status/lib/Migration/Version1008Date20230921144701.php
new file mode 100644
index 00000000000..30ebbf37b0e
--- /dev/null
+++ b/apps/user_status/lib/Migration/Version1008Date20230921144701.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UserStatus\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\IDBConnection;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+class Version1008Date20230921144701 extends SimpleMigrationStep {
+
+ public function __construct(
+ private IDBConnection $connection,
+ ) {
+ }
+
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $statusTable = $schema->getTable('user_status');
+ if (!($statusTable->hasColumn('status_message_timestamp'))) {
+ $statusTable->addColumn('status_message_timestamp', Types::INTEGER, [
+ 'notnull' => true,
+ 'length' => 11,
+ 'unsigned' => true,
+ 'default' => 0,
+ ]);
+ }
+ if (!$statusTable->hasIndex('user_status_mtstmp_ix')) {
+ $statusTable->addIndex(['status_message_timestamp'], 'user_status_mtstmp_ix');
+ }
+
+ return $schema;
+ }
+
+ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ $qb = $this->connection->getQueryBuilder();
+
+ $update = $qb->update('user_status')
+ ->set('status_message_timestamp', 'status_timestamp');
+
+ $update->executeStatement();
+ }
+}
diff --git a/apps/user_status/lib/ResponseDefinitions.php b/apps/user_status/lib/ResponseDefinitions.php
new file mode 100644
index 00000000000..82f606dd301
--- /dev/null
+++ b/apps/user_status/lib/ResponseDefinitions.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\UserStatus;
+
+/**
+ * @psalm-type UserStatusClearAtTimeType = "day"|"week"
+ *
+ * @psalm-type UserStatusClearAt = array{
+ * type: "period"|"end-of",
+ * time: int|UserStatusClearAtTimeType,
+ * }
+ *
+ * @psalm-type UserStatusPredefined = array{
+ * id: string,
+ * icon: string,
+ * message: string,
+ * clearAt: ?UserStatusClearAt,
+ * }
+ *
+ * @psalm-type UserStatusType = "online"|"away"|"dnd"|"busy"|"offline"|"invisible"
+ *
+ * @psalm-type UserStatusPublic = array{
+ * userId: string,
+ * message: ?string,
+ * icon: ?string,
+ * clearAt: ?int,
+ * status: UserStatusType,
+ * }
+ *
+ * @psalm-type UserStatusPrivate = UserStatusPublic&array{
+ * messageId: ?string,
+ * messageIsPredefined: bool,
+ * statusIsUserDefined: bool,
+ * }
+ */
+class ResponseDefinitions {
+}
diff --git a/apps/user_status/lib/Service/JSDataService.php b/apps/user_status/lib/Service/JSDataService.php
new file mode 100644
index 00000000000..a777e97fe57
--- /dev/null
+++ b/apps/user_status/lib/Service/JSDataService.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Service;
+
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\IUserSession;
+use OCP\UserStatus\IUserStatus;
+
+class JSDataService implements \JsonSerializable {
+
+ /**
+ * JSDataService constructor.
+ *
+ * @param IUserSession $userSession
+ * @param StatusService $statusService
+ */
+ public function __construct(
+ private IUserSession $userSession,
+ private StatusService $statusService,
+ ) {
+ }
+
+ public function jsonSerialize(): array {
+ $user = $this->userSession->getUser();
+
+ if ($user === null) {
+ return [];
+ }
+
+ try {
+ $status = $this->statusService->findByUserId($user->getUID());
+ } catch (DoesNotExistException $ex) {
+ return [
+ 'userId' => $user->getUID(),
+ 'message' => null,
+ 'messageId' => null,
+ 'messageIsPredefined' => false,
+ 'icon' => null,
+ 'clearAt' => null,
+ 'status' => IUserStatus::OFFLINE,
+ 'statusIsUserDefined' => false,
+ ];
+ }
+
+ return [
+ 'userId' => $status->getUserId(),
+ 'message' => $status->getCustomMessage(),
+ 'messageId' => $status->getMessageId(),
+ 'messageIsPredefined' => $status->getMessageId() !== null,
+ 'icon' => $status->getCustomIcon(),
+ 'clearAt' => $status->getClearAt(),
+ 'status' => $status->getStatus(),
+ 'statusIsUserDefined' => $status->getIsUserDefined(),
+ ];
+ }
+}
diff --git a/apps/user_status/lib/Service/PredefinedStatusService.php b/apps/user_status/lib/Service/PredefinedStatusService.php
new file mode 100644
index 00000000000..599d5b8b52f
--- /dev/null
+++ b/apps/user_status/lib/Service/PredefinedStatusService.php
@@ -0,0 +1,223 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Service;
+
+use OCP\IL10N;
+use OCP\UserStatus\IUserStatus;
+
+/**
+ * Class DefaultStatusService
+ *
+ * We are offering a set of default statuses, so we can
+ * translate them into different languages.
+ *
+ * @package OCA\UserStatus\Service
+ */
+class PredefinedStatusService {
+ private const BE_RIGHT_BACK = 'be-right-back';
+ private const MEETING = 'meeting';
+ private const COMMUTING = 'commuting';
+ private const SICK_LEAVE = 'sick-leave';
+ private const VACATIONING = 'vacationing';
+ private const REMOTE_WORK = 'remote-work';
+ /**
+ * @deprecated See \OCP\UserStatus\IUserStatus::MESSAGE_CALL
+ */
+ public const CALL = 'call';
+ public const OUT_OF_OFFICE = 'out-of-office';
+
+ /**
+ * DefaultStatusService constructor.
+ *
+ * @param IL10N $l10n
+ */
+ public function __construct(
+ private IL10N $l10n,
+ ) {
+ }
+
+ /**
+ * @return array
+ */
+ public function getDefaultStatuses(): array {
+ return [
+ [
+ 'id' => self::MEETING,
+ 'icon' => '📅',
+ 'message' => $this->getTranslatedStatusForId(self::MEETING),
+ 'clearAt' => [
+ 'type' => 'period',
+ 'time' => 3600,
+ ],
+ ],
+ [
+ 'id' => self::COMMUTING,
+ 'icon' => '🚌',
+ 'message' => $this->getTranslatedStatusForId(self::COMMUTING),
+ 'clearAt' => [
+ 'type' => 'period',
+ 'time' => 1800,
+ ],
+ ],
+ [
+ 'id' => self::BE_RIGHT_BACK,
+ 'icon' => '⏳',
+ 'message' => $this->getTranslatedStatusForId(self::BE_RIGHT_BACK),
+ 'clearAt' => [
+ 'type' => 'period',
+ 'time' => 900,
+ ],
+ ],
+ [
+ 'id' => self::REMOTE_WORK,
+ 'icon' => '🏡',
+ 'message' => $this->getTranslatedStatusForId(self::REMOTE_WORK),
+ 'clearAt' => [
+ 'type' => 'end-of',
+ 'time' => 'day',
+ ],
+ ],
+ [
+ 'id' => self::SICK_LEAVE,
+ 'icon' => '🤒',
+ 'message' => $this->getTranslatedStatusForId(self::SICK_LEAVE),
+ 'clearAt' => [
+ 'type' => 'end-of',
+ 'time' => 'day',
+ ],
+ ],
+ [
+ 'id' => self::VACATIONING,
+ 'icon' => '🌴',
+ 'message' => $this->getTranslatedStatusForId(self::VACATIONING),
+ 'clearAt' => null,
+ ],
+ [
+ 'id' => self::CALL,
+ 'icon' => '💬',
+ 'message' => $this->getTranslatedStatusForId(self::CALL),
+ 'clearAt' => null,
+ 'visible' => false,
+ ],
+ [
+ 'id' => self::OUT_OF_OFFICE,
+ 'icon' => '🛑',
+ 'message' => $this->getTranslatedStatusForId(self::OUT_OF_OFFICE),
+ 'clearAt' => null,
+ 'visible' => false,
+ ],
+ ];
+ }
+
+ /**
+ * @param string $id
+ * @return array|null
+ */
+ public function getDefaultStatusById(string $id): ?array {
+ foreach ($this->getDefaultStatuses() as $status) {
+ if ($status['id'] === $id) {
+ return $status;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @param string $id
+ * @return string|null
+ */
+ public function getIconForId(string $id): ?string {
+ switch ($id) {
+ case self::MEETING:
+ return '📅';
+
+ case self::COMMUTING:
+ return '🚌';
+
+ case self::SICK_LEAVE:
+ return '🤒';
+
+ case self::VACATIONING:
+ return '🌴';
+
+ case self::OUT_OF_OFFICE:
+ return '🛑';
+
+ case self::REMOTE_WORK:
+ return '🏡';
+
+ case self::BE_RIGHT_BACK:
+ return '⏳';
+
+ case self::CALL:
+ return '💬';
+
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * @param string $lang
+ * @param string $id
+ * @return string|null
+ */
+ public function getTranslatedStatusForId(string $id): ?string {
+ switch ($id) {
+ case self::MEETING:
+ return $this->l10n->t('In a meeting');
+
+ case self::COMMUTING:
+ return $this->l10n->t('Commuting');
+
+ case self::SICK_LEAVE:
+ return $this->l10n->t('Out sick');
+
+ case self::VACATIONING:
+ return $this->l10n->t('Vacationing');
+
+ case self::OUT_OF_OFFICE:
+ return $this->l10n->t('Out of office');
+
+ case self::REMOTE_WORK:
+ return $this->l10n->t('Working remotely');
+
+ case self::CALL:
+ return $this->l10n->t('In a call');
+
+ case self::BE_RIGHT_BACK:
+ return $this->l10n->t('Be right back');
+
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * @param string $id
+ * @return bool
+ */
+ public function isValidId(string $id): bool {
+ return \in_array($id, [
+ self::MEETING,
+ self::COMMUTING,
+ self::SICK_LEAVE,
+ self::VACATIONING,
+ self::OUT_OF_OFFICE,
+ self::BE_RIGHT_BACK,
+ self::REMOTE_WORK,
+ IUserStatus::MESSAGE_CALL,
+ IUserStatus::MESSAGE_AVAILABILITY,
+ IUserStatus::MESSAGE_VACATION,
+ IUserStatus::MESSAGE_CALENDAR_BUSY,
+ IUserStatus::MESSAGE_CALENDAR_BUSY_TENTATIVE,
+ ], true);
+ }
+}
diff --git a/apps/user_status/lib/Service/StatusService.php b/apps/user_status/lib/Service/StatusService.php
new file mode 100644
index 00000000000..188eb26d1d7
--- /dev/null
+++ b/apps/user_status/lib/Service/StatusService.php
@@ -0,0 +1,599 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\UserStatus\Service;
+
+use OCA\UserStatus\Db\UserStatus;
+use OCA\UserStatus\Db\UserStatusMapper;
+use OCA\UserStatus\Exception\InvalidClearAtException;
+use OCA\UserStatus\Exception\InvalidMessageIdException;
+use OCA\UserStatus\Exception\InvalidStatusIconException;
+use OCA\UserStatus\Exception\InvalidStatusTypeException;
+use OCA\UserStatus\Exception\StatusMessageTooLongException;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\DB\Exception;
+use OCP\IConfig;
+use OCP\IEmojiHelper;
+use OCP\IUserManager;
+use OCP\UserStatus\IUserStatus;
+use Psr\Log\LoggerInterface;
+use function in_array;
+
+/**
+ * Class StatusService
+ *
+ * @package OCA\UserStatus\Service
+ */
+class StatusService {
+ private bool $shareeEnumeration;
+ private bool $shareeEnumerationInGroupOnly;
+ private bool $shareeEnumerationPhone;
+
+ /**
+ * List of priorities ordered by their priority
+ */
+ public const PRIORITY_ORDERED_STATUSES = [
+ IUserStatus::ONLINE,
+ IUserStatus::AWAY,
+ IUserStatus::DND,
+ IUserStatus::BUSY,
+ IUserStatus::INVISIBLE,
+ IUserStatus::OFFLINE,
+ ];
+
+ /**
+ * List of statuses that persist the clear-up
+ * or UserLiveStatusEvents
+ */
+ public const PERSISTENT_STATUSES = [
+ IUserStatus::AWAY,
+ IUserStatus::BUSY,
+ IUserStatus::DND,
+ IUserStatus::INVISIBLE,
+ ];
+
+ /** @var int */
+ public const INVALIDATE_STATUS_THRESHOLD = 15 /* minutes */ * 60 /* seconds */;
+
+ /** @var int */
+ public const MAXIMUM_MESSAGE_LENGTH = 80;
+
+ public function __construct(
+ private UserStatusMapper $mapper,
+ private ITimeFactory $timeFactory,
+ private PredefinedStatusService $predefinedStatusService,
+ private IEmojiHelper $emojiHelper,
+ private IConfig $config,
+ private IUserManager $userManager,
+ private LoggerInterface $logger,
+ ) {
+ $this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
+ $this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
+ $this->shareeEnumerationPhone = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
+ }
+
+ /**
+ * @param int|null $limit
+ * @param int|null $offset
+ * @return UserStatus[]
+ */
+ public function findAll(?int $limit = null, ?int $offset = null): array {
+ // Return empty array if user enumeration is disabled or limited to groups
+ // TODO: find a solution that scales to get only users from common groups if user enumeration is limited to
+ // groups. See discussion at https://github.com/nextcloud/server/pull/27879#discussion_r729715936
+ if (!$this->shareeEnumeration || $this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone) {
+ return [];
+ }
+
+ return array_map(function ($status) {
+ return $this->processStatus($status);
+ }, $this->mapper->findAll($limit, $offset));
+ }
+
+ /**
+ * @param int|null $limit
+ * @param int|null $offset
+ * @return array
+ */
+ public function findAllRecentStatusChanges(?int $limit = null, ?int $offset = null): array {
+ // Return empty array if user enumeration is disabled or limited to groups
+ // TODO: find a solution that scales to get only users from common groups if user enumeration is limited to
+ // groups. See discussion at https://github.com/nextcloud/server/pull/27879#discussion_r729715936
+ if (!$this->shareeEnumeration || $this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone) {
+ return [];
+ }
+
+ return array_map(function ($status) {
+ return $this->processStatus($status);
+ }, $this->mapper->findAllRecent($limit, $offset));
+ }
+
+ /**
+ * @param string $userId
+ * @return UserStatus
+ * @throws DoesNotExistException
+ */
+ public function findByUserId(string $userId): UserStatus {
+ return $this->processStatus($this->mapper->findByUserId($userId));
+ }
+
+ /**
+ * @param array $userIds
+ * @return UserStatus[]
+ */
+ public function findByUserIds(array $userIds):array {
+ return array_map(function ($status) {
+ return $this->processStatus($status);
+ }, $this->mapper->findByUserIds($userIds));
+ }
+
+ /**
+ * @param string $userId
+ * @param string $status
+ * @param int|null $statusTimestamp
+ * @param bool $isUserDefined
+ * @return UserStatus
+ * @throws InvalidStatusTypeException
+ */
+ public function setStatus(string $userId,
+ string $status,
+ ?int $statusTimestamp,
+ bool $isUserDefined): UserStatus {
+ try {
+ $userStatus = $this->mapper->findByUserId($userId);
+ } catch (DoesNotExistException $ex) {
+ $userStatus = new UserStatus();
+ $userStatus->setUserId($userId);
+ }
+
+ // Check if status-type is valid
+ if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
+ throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
+ }
+
+ if ($statusTimestamp === null) {
+ $statusTimestamp = $this->timeFactory->getTime();
+ }
+
+ $userStatus->setStatus($status);
+ $userStatus->setStatusTimestamp($statusTimestamp);
+ $userStatus->setIsUserDefined($isUserDefined);
+ $userStatus->setIsBackup(false);
+
+ if ($userStatus->getId() === null) {
+ return $this->insertWithoutThrowingUniqueConstrain($userStatus);
+ }
+
+ return $this->mapper->update($userStatus);
+ }
+
+ /**
+ * @param string $userId
+ * @param string $messageId
+ * @param int|null $clearAt
+ * @return UserStatus
+ * @throws InvalidMessageIdException
+ * @throws InvalidClearAtException
+ */
+ public function setPredefinedMessage(string $userId,
+ string $messageId,
+ ?int $clearAt): UserStatus {
+ try {
+ $userStatus = $this->mapper->findByUserId($userId);
+ } catch (DoesNotExistException $ex) {
+ $userStatus = new UserStatus();
+ $userStatus->setUserId($userId);
+ $userStatus->setStatus(IUserStatus::OFFLINE);
+ $userStatus->setStatusTimestamp(0);
+ $userStatus->setIsUserDefined(false);
+ $userStatus->setIsBackup(false);
+ }
+
+ if (!$this->predefinedStatusService->isValidId($messageId)) {
+ throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
+ }
+
+ // Check that clearAt is in the future
+ if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
+ throw new InvalidClearAtException('ClearAt is in the past');
+ }
+
+ $userStatus->setMessageId($messageId);
+ $userStatus->setCustomIcon(null);
+ $userStatus->setCustomMessage(null);
+ $userStatus->setClearAt($clearAt);
+ $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
+
+ if ($userStatus->getId() === null) {
+ return $this->insertWithoutThrowingUniqueConstrain($userStatus);
+ }
+
+ return $this->mapper->update($userStatus);
+ }
+
+ /**
+ * @param string $userId
+ * @param string $status
+ * @param string $messageId
+ * @param bool $createBackup
+ * @param string|null $customMessage
+ * @throws InvalidStatusTypeException
+ * @throws InvalidMessageIdException
+ */
+ public function setUserStatus(string $userId,
+ string $status,
+ string $messageId,
+ bool $createBackup,
+ ?string $customMessage = null): ?UserStatus {
+ // Check if status-type is valid
+ if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
+ throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
+ }
+
+ if (!$this->predefinedStatusService->isValidId($messageId)) {
+ throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
+ }
+
+ try {
+ $userStatus = $this->mapper->findByUserId($userId);
+ } catch (DoesNotExistException $e) {
+ // We don't need to do anything
+ $userStatus = new UserStatus();
+ $userStatus->setUserId($userId);
+ }
+
+ $updateStatus = false;
+ if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE) {
+ // OUT_OF_OFFICE trumps AVAILABILITY, CALL and CALENDAR status
+ $updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_AVAILABILITY || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
+ } elseif ($messageId === IUserStatus::MESSAGE_AVAILABILITY) {
+ // AVAILABILITY trumps CALL and CALENDAR status
+ $updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
+ } elseif ($messageId === IUserStatus::MESSAGE_CALL) {
+ // CALL trumps CALENDAR status
+ $updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
+ }
+
+ if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE || $messageId === IUserStatus::MESSAGE_AVAILABILITY || $messageId === IUserStatus::MESSAGE_CALL || $messageId === IUserStatus::MESSAGE_CALENDAR_BUSY) {
+ if ($updateStatus) {
+ $this->logger->debug('User ' . $userId . ' is currently NOT available, overwriting status [status: ' . $userStatus->getStatus() . ', messageId: ' . json_encode($userStatus->getMessageId()) . ']', ['app' => 'dav']);
+ } else {
+ $this->logger->debug('User ' . $userId . ' is currently NOT available, but we are NOT overwriting status [status: ' . $userStatus->getStatus() . ', messageId: ' . json_encode($userStatus->getMessageId()) . ']', ['app' => 'dav']);
+ }
+ }
+
+ // There should be a backup already or none is needed. So we take a shortcut.
+ if ($updateStatus) {
+ $userStatus->setStatus($status);
+ $userStatus->setStatusTimestamp($this->timeFactory->getTime());
+ $userStatus->setIsUserDefined(true);
+ $userStatus->setIsBackup(false);
+ $userStatus->setMessageId($messageId);
+ $userStatus->setCustomIcon(null);
+ $userStatus->setCustomMessage($customMessage);
+ $userStatus->setClearAt(null);
+ $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
+ return $this->mapper->update($userStatus);
+ }
+
+ if ($createBackup) {
+ if ($this->backupCurrentStatus($userId) === false) {
+ return null; // Already a status set automatically => abort.
+ }
+
+ // If we just created the backup
+ // we need to create a new status to insert
+ // Unfortunately there's no way to unset the DB ID on an Entity
+ $userStatus = new UserStatus();
+ $userStatus->setUserId($userId);
+ }
+
+ $userStatus->setStatus($status);
+ $userStatus->setStatusTimestamp($this->timeFactory->getTime());
+ $userStatus->setIsUserDefined(true);
+ $userStatus->setIsBackup(false);
+ $userStatus->setMessageId($messageId);
+ $userStatus->setCustomIcon(null);
+ $userStatus->setCustomMessage($customMessage);
+ $userStatus->setClearAt(null);
+ if ($this->predefinedStatusService->getTranslatedStatusForId($messageId) !== null
+ || ($customMessage !== null && $customMessage !== '')) {
+ // Only track status message ID if there is one
+ $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
+ } else {
+ $userStatus->setStatusMessageTimestamp(0);
+ }
+
+ if ($userStatus->getId() !== null) {
+ return $this->mapper->update($userStatus);
+ }
+ return $this->insertWithoutThrowingUniqueConstrain($userStatus);
+ }
+
+ /**
+ * @param string $userId
+ * @param string|null $statusIcon
+ * @param string|null $message
+ * @param int|null $clearAt
+ * @return UserStatus
+ * @throws InvalidClearAtException
+ * @throws InvalidStatusIconException
+ * @throws StatusMessageTooLongException
+ */
+ public function setCustomMessage(string $userId,
+ ?string $statusIcon,
+ ?string $message,
+ ?int $clearAt): UserStatus {
+ try {
+ $userStatus = $this->mapper->findByUserId($userId);
+ } catch (DoesNotExistException $ex) {
+ $userStatus = new UserStatus();
+ $userStatus->setUserId($userId);
+ $userStatus->setStatus(IUserStatus::OFFLINE);
+ $userStatus->setStatusTimestamp(0);
+ $userStatus->setIsUserDefined(false);
+ }
+
+ // Check if statusIcon contains only one character
+ if ($statusIcon !== null && !$this->emojiHelper->isValidSingleEmoji($statusIcon)) {
+ throw new InvalidStatusIconException('Status-Icon is longer than one character');
+ }
+ // Check for maximum length of custom message
+ if ($message !== null && \mb_strlen($message) > self::MAXIMUM_MESSAGE_LENGTH) {
+ throw new StatusMessageTooLongException('Message is longer than supported length of ' . self::MAXIMUM_MESSAGE_LENGTH . ' characters');
+ }
+ // Check that clearAt is in the future
+ if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
+ throw new InvalidClearAtException('ClearAt is in the past');
+ }
+
+ $userStatus->setMessageId(null);
+ $userStatus->setCustomIcon($statusIcon);
+ $userStatus->setCustomMessage($message);
+ $userStatus->setClearAt($clearAt);
+ $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
+
+ if ($userStatus->getId() === null) {
+ return $this->insertWithoutThrowingUniqueConstrain($userStatus);
+ }
+
+ return $this->mapper->update($userStatus);
+ }
+
+ /**
+ * @param string $userId
+ * @return bool
+ */
+ public function clearStatus(string $userId): bool {
+ try {
+ $userStatus = $this->mapper->findByUserId($userId);
+ } catch (DoesNotExistException $ex) {
+ // if there is no status to remove, just return
+ return false;
+ }
+
+ $userStatus->setStatus(IUserStatus::OFFLINE);
+ $userStatus->setStatusTimestamp(0);
+ $userStatus->setIsUserDefined(false);
+
+ $this->mapper->update($userStatus);
+ return true;
+ }
+
+ /**
+ * @param string $userId
+ * @return bool
+ */
+ public function clearMessage(string $userId): bool {
+ try {
+ $userStatus = $this->mapper->findByUserId($userId);
+ } catch (DoesNotExistException $ex) {
+ // if there is no status to remove, just return
+ return false;
+ }
+
+ $userStatus->setMessageId(null);
+ $userStatus->setCustomMessage(null);
+ $userStatus->setCustomIcon(null);
+ $userStatus->setClearAt(null);
+ $userStatus->setStatusMessageTimestamp(0);
+
+ $this->mapper->update($userStatus);
+ return true;
+ }
+
+ /**
+ * @param string $userId
+ * @return bool
+ */
+ public function removeUserStatus(string $userId): bool {
+ try {
+ $userStatus = $this->mapper->findByUserId($userId, false);
+ } catch (DoesNotExistException $ex) {
+ // if there is no status to remove, just return
+ return false;
+ }
+
+ $this->mapper->delete($userStatus);
+ return true;
+ }
+
+ public function removeBackupUserStatus(string $userId): bool {
+ try {
+ $userStatus = $this->mapper->findByUserId($userId, true);
+ } catch (DoesNotExistException $ex) {
+ // if there is no status to remove, just return
+ return false;
+ }
+
+ $this->mapper->delete($userStatus);
+ return true;
+ }
+
+ /**
+ * Processes a status to check if custom message is still
+ * up to date and provides translated default status if needed
+ *
+ * @param UserStatus $status
+ * @return UserStatus
+ */
+ private function processStatus(UserStatus $status): UserStatus {
+ $clearAt = $status->getClearAt();
+
+ if ($status->getStatusTimestamp() < $this->timeFactory->getTime() - self::INVALIDATE_STATUS_THRESHOLD
+ && (!$status->getIsUserDefined() || $status->getStatus() === IUserStatus::ONLINE)) {
+ $this->cleanStatus($status);
+ }
+ if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
+ $this->cleanStatus($status);
+ $this->cleanStatusMessage($status);
+ }
+ if ($status->getMessageId() !== null) {
+ $this->addDefaultMessage($status);
+ }
+
+ return $status;
+ }
+
+ /**
+ * @param UserStatus $status
+ */
+ private function cleanStatus(UserStatus $status): void {
+ if ($status->getStatus() === IUserStatus::OFFLINE && !$status->getIsUserDefined()) {
+ return;
+ }
+
+ $status->setStatus(IUserStatus::OFFLINE);
+ $status->setStatusTimestamp($this->timeFactory->getTime());
+ $status->setIsUserDefined(false);
+
+ $this->mapper->update($status);
+ }
+
+ /**
+ * @param UserStatus $status
+ */
+ private function cleanStatusMessage(UserStatus $status): void {
+ $status->setMessageId(null);
+ $status->setCustomIcon(null);
+ $status->setCustomMessage(null);
+ $status->setClearAt(null);
+ $status->setStatusMessageTimestamp(0);
+
+ $this->mapper->update($status);
+ }
+
+ /**
+ * @param UserStatus $status
+ */
+ private function addDefaultMessage(UserStatus $status): void {
+ // If the message is predefined, insert the translated message and icon
+ $predefinedMessage = $this->predefinedStatusService->getDefaultStatusById($status->getMessageId());
+ if ($predefinedMessage === null) {
+ return;
+ }
+ // If there is a custom message, don't overwrite it
+ if (empty($status->getCustomMessage())) {
+ $status->setCustomMessage($predefinedMessage['message']);
+ }
+ if (empty($status->getCustomIcon())) {
+ $status->setCustomIcon($predefinedMessage['icon']);
+ }
+ }
+
+ /**
+ * @return bool false if there is already a backup. In this case abort the procedure.
+ */
+ public function backupCurrentStatus(string $userId): bool {
+ try {
+ $this->mapper->createBackupStatus($userId);
+ return true;
+ } catch (Exception $ex) {
+ if ($ex->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
+ return false;
+ }
+ throw $ex;
+ }
+ }
+
+ public function revertUserStatus(string $userId, string $messageId, bool $revertedManually = false): ?UserStatus {
+ try {
+ /** @var UserStatus $userStatus */
+ $backupUserStatus = $this->mapper->findByUserId($userId, true);
+ } catch (DoesNotExistException $ex) {
+ // No user status to revert, do nothing
+ return null;
+ }
+
+ $deleted = $this->mapper->deleteCurrentStatusToRestoreBackup($userId, $messageId);
+ if (!$deleted) {
+ // Another status is set automatically or no status, do nothing
+ return null;
+ }
+
+ if ($revertedManually) {
+ if ($backupUserStatus->getStatus() === IUserStatus::OFFLINE) {
+ // When the user reverts the status manually they are online
+ $backupUserStatus->setStatus(IUserStatus::ONLINE);
+ }
+ $backupUserStatus->setStatusTimestamp($this->timeFactory->getTime());
+ }
+
+ $backupUserStatus->setIsBackup(false);
+ // Remove the underscore prefix added when creating the backup
+ $backupUserStatus->setUserId(substr($backupUserStatus->getUserId(), 1));
+ $this->mapper->update($backupUserStatus);
+
+ return $backupUserStatus;
+ }
+
+ public function revertMultipleUserStatus(array $userIds, string $messageId): void {
+ // Get all user statuses and the backups
+ $findById = $userIds;
+ foreach ($userIds as $userId) {
+ $findById[] = '_' . $userId;
+ }
+ $userStatuses = $this->mapper->findByUserIds($findById);
+
+ $backups = $restoreIds = $statuesToDelete = [];
+ foreach ($userStatuses as $userStatus) {
+ if (!$userStatus->getIsBackup()
+ && $userStatus->getMessageId() === $messageId) {
+ $statuesToDelete[$userStatus->getUserId()] = $userStatus->getId();
+ } elseif ($userStatus->getIsBackup()) {
+ $backups[$userStatus->getUserId()] = $userStatus->getId();
+ }
+ }
+
+ // For users with both (normal and backup) delete the status when matching
+ foreach ($statuesToDelete as $userId => $statusId) {
+ $backupUserId = '_' . $userId;
+ if (isset($backups[$backupUserId])) {
+ $restoreIds[] = $backups[$backupUserId];
+ }
+ }
+
+ $this->mapper->deleteByIds(array_values($statuesToDelete));
+
+ // For users that matched restore the previous status
+ $this->mapper->restoreBackupStatuses($restoreIds);
+ }
+
+ protected function insertWithoutThrowingUniqueConstrain(UserStatus $userStatus): UserStatus {
+ try {
+ return $this->mapper->insert($userStatus);
+ } catch (Exception $e) {
+ // Ignore if a parallel request already set the status
+ if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
+ throw $e;
+ }
+ }
+ return $userStatus;
+ }
+}