diff options
Diffstat (limited to 'apps/user_status/lib')
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; + } +} |