Browse Source

feat(user status): automate user status for events

and automatically set a user status to free or busy depending on their calendar
transparency, event status and availability settings

Signed-off-by: Anna Larch <anna@nextcloud.com>
pull/40615/head
Anna Larch 2 years ago
parent
commit
f14a4f8fd7
  1. 3
      apps/dav/composer/composer/autoload_classmap.php
  2. 3
      apps/dav/composer/composer/autoload_static.php
  3. 27
      apps/dav/lib/CalDAV/CalendarImpl.php
  4. 44
      apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php
  5. 2
      apps/dav/lib/CalDAV/Schedule/Plugin.php
  6. 57
      apps/dav/lib/CalDAV/Status/Status.php
  7. 236
      apps/dav/lib/CalDAV/Status/StatusService.php
  8. 1508
      apps/dav/tests/unit/CalDAV/Status/StatusServiceTest.php
  9. 20
      apps/user_status/lib/Db/UserStatusMapper.php
  10. 2
      apps/user_status/lib/Listener/UserLiveStatusListener.php
  11. 2
      apps/user_status/lib/Service/PredefinedStatusService.php
  12. 140
      apps/user_status/lib/Service/StatusService.php
  13. 2
      apps/user_status/src/mixins/OnlineStatusMixin.js
  14. 345
      apps/user_status/tests/Unit/Service/StatusServiceTest.php
  15. 4
      dist/user_status-menu.js
  16. 2
      dist/user_status-menu.js.map
  17. 18
      lib/public/UserStatus/IUserStatus.php

3
apps/dav/composer/composer/autoload_classmap.php

@ -55,6 +55,7 @@ return array(
'OCA\\DAV\\CalDAV\\CalendarProvider' => $baseDir . '/../lib/CalDAV/CalendarProvider.php',
'OCA\\DAV\\CalDAV\\CalendarRoot' => $baseDir . '/../lib/CalDAV/CalendarRoot.php',
'OCA\\DAV\\CalDAV\\EventComparisonService' => $baseDir . '/../lib/CalDAV/EventComparisonService.php',
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php',
'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => $baseDir . '/../lib/CalDAV/Integration/ExternalCalendar.php',
@ -97,6 +98,8 @@ return array(
'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/PropFilter.php',
'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php',
'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => $baseDir . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php',
'OCA\\DAV\\CalDAV\\Status\\Status' => $baseDir . '/../lib/CalDAV/Status/Status.php',
'OCA\\DAV\\CalDAV\\Status\\StatusService' => $baseDir . '/../lib/CalDAV/Status/StatusService.php',
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php',
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObjectsCollection' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php',
'OCA\\DAV\\CalDAV\\Trashbin\\Plugin' => $baseDir . '/../lib/CalDAV/Trashbin/Plugin.php',

3
apps/dav/composer/composer/autoload_static.php

@ -70,6 +70,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\CalendarProvider' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarProvider.php',
'OCA\\DAV\\CalDAV\\CalendarRoot' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarRoot.php',
'OCA\\DAV\\CalDAV\\EventComparisonService' => __DIR__ . '/..' . '/../lib/CalDAV/EventComparisonService.php',
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php',
'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ExternalCalendar.php',
@ -112,6 +113,8 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/PropFilter.php',
'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php',
'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php',
'OCA\\DAV\\CalDAV\\Status\\Status' => __DIR__ . '/..' . '/../lib/CalDAV/Status/Status.php',
'OCA\\DAV\\CalDAV\\Status\\StatusService' => __DIR__ . '/..' . '/../lib/CalDAV/Status/StatusService.php',
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php',
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObjectsCollection' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php',
'OCA\\DAV\\CalDAV\\Trashbin\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/Plugin.php',

27
apps/dav/lib/CalDAV/CalendarImpl.php

@ -33,11 +33,15 @@ use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
use OCP\Calendar\Exceptions\CalendarException;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IHandleImipMessage;
use OCP\Calendar\ISchedulingInformation;
use OCP\Constants;
use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
use Sabre\DAV\Exception\Conflict;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Component\VTimeZone;
use Sabre\VObject\ITip\Message;
use Sabre\VObject\Property;
use Sabre\VObject\Reader;
use function Sabre\Uri\split as uriSplit;
@ -86,6 +90,29 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage {
return $this->calendarInfo['{http://apple.com/ns/ical/}calendar-color'];
}
public function getSchedulingTransparency(): ?ScheduleCalendarTransp {
return $this->calendarInfo['{' . \OCA\DAV\CalDAV\Schedule\Plugin::NS_CALDAV . '}schedule-calendar-transp'];
}
public function getSchedulingTimezone(): ?VTimeZone {
$tzProp = '{' . \OCA\DAV\CalDAV\Schedule\Plugin::NS_CALDAV . '}calendar-timezone';
if (!isset($this->calendarInfo[$tzProp])) {
return null;
}
// This property contains a VCALENDAR with a single VTIMEZONE
/** @var string $timezoneProp */
$timezoneProp = $this->calendarInfo[$tzProp];
/** @var VCalendar $vobj */
$vobj = Reader::read($timezoneProp);
$components = $vobj->getComponents();
if(empty($components)) {
return null;
}
/** @var VTimeZone $vtimezone */
$vtimezone = $components[0];
return $vtimezone;
}
/**
* @param string $pattern which should match within the $searchProperties
* @param array $searchProperties defines the properties within the query pattern should match

44
apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* *
* *
* * @copyright 2023 Anna Larch <anna.larch@gmx.net>
* *
* * @author Anna Larch <anna.larch@gmx.net>
* *
* * This library is free software; you can redistribute it and/or
* * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* * License as published by the Free Software Foundation; either
* * version 3 of the License, or any later version.
* *
* * This library is distributed in the hope that it will be useful,
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
* *
* * You should have received a copy of the GNU Affero General Public
* * License along with this library. If not, see <http://www.gnu.org/licenses/>.
* *
*
*/
namespace OCA\DAV\CalDAV\FreeBusy;
use DateTimeInterface;
use DateTimeZone;
use Sabre\VObject\Component\VCalendar;
/**
* @psalm-suppress PropertyNotSetInConstructor
*/
class FreeBusyGenerator extends \Sabre\VObject\FreeBusyGenerator {
public function __construct() {
parent::__construct();
}
public function getVCalendar(): VCalendar {
return new VCalendar();
}
}

2
apps/dav/lib/CalDAV/Schedule/Plugin.php

@ -36,6 +36,7 @@ use OCA\DAV\CalDAV\CalendarHome;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\ICalendar;
use Sabre\CalDAV\Schedule\IOutbox;
use Sabre\DAV\INode;
use Sabre\DAV\IProperties;
use Sabre\DAV\PropFind;
@ -44,6 +45,7 @@ use Sabre\DAV\Xml\Property\LocalHref;
use Sabre\DAVACL\IPrincipal;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Sabre\VObject;
use Sabre\VObject\Component;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;

57
apps/dav/lib/CalDAV/Status/Status.php

@ -0,0 +1,57 @@
<?php
/*
* *
* * Dav App
* *
* * @copyright 2023 Anna Larch <anna.larch@gmx.net>
* *
* * @author Anna Larch <anna.larch@gmx.net>
* *
* * This library is free software; you can redistribute it and/or
* * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* * License as published by the Free Software Foundation; either
* * version 3 of the License, or any later version.
* *
* * This library is distributed in the hope that it will be useful,
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
* *
* * You should have received a copy of the GNU Affero General Public
* * License along with this library. If not, see <http://www.gnu.org/licenses/>.
* *
*
*/
namespace OCA\DAV\CalDAV\Status;
class Status {
public function __construct(private string $status = '', private ?string $message = null, private ?string $customMessage = null){}
public function getStatus(): string {
return $this->status;
}
public function setStatus(string $status): void {
$this->status = $status;
}
public function getMessage(): ?string {
return $this->message;
}
public function setMessage(?string $message): void {
$this->message = $message;
}
public function getCustomMessage(): ?string {
return $this->customMessage;
}
public function setCustomMessage(?string $customMessage): void {
$this->customMessage = $customMessage;
}
}

236
apps/dav/lib/CalDAV/Status/StatusService.php

@ -0,0 +1,236 @@
<?php
/*
* *
* * Dav App
* *
* * @copyright 2023 Anna Larch <anna.larch@gmx.net>
* *
* * @author Anna Larch <anna.larch@gmx.net>
* *
* * This library is free software; you can redistribute it and/or
* * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* * License as published by the Free Software Foundation; either
* * version 3 of the License, or any later version.
* *
* * This library is distributed in the hope that it will be useful,
* * but WITHOUT ANY WARRANTY; without even the implied warranty of
* * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
* *
* * You should have received a copy of the GNU Affero General Public
* * License along with this library. If not, see <http://www.gnu.org/licenses/>.
* *
*
*/
declare(strict_types=1);
/**
* @copyright 2023 Anna Larch <anna.larch@gmx.net>
*
* @author Anna Larch <anna.larch@gmx.net>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\DAV\CalDAV\Status;
use DateTimeZone;
use OC\Calendar\CalendarQuery;
use OCA\DAV\CalDAV\CalendarImpl;
use OCA\DAV\CalDAV\FreeBusy\FreeBusyGenerator;
use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
use OCA\DAV\CalDAV\IUser;
use OCA\DAV\CalDAV\Schedule\Plugin as SchedulePlugin;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Calendar\IManager;
use OCP\Calendar\ISchedulingInformation;
use OCP\IL10N;
use OCP\IUser as User;
use OCP\UserStatus\IUserStatus;
use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
use Sabre\DAV\Exception\NotAuthenticated;
use Sabre\DAVACL\Exception\NeedPrivileges;
use Sabre\DAVACL\Plugin as AclPlugin;
use Sabre\VObject\Component;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Parameter;
use Sabre\VObject\Property;
use Sabre\VObject\Reader;
class StatusService {
public function __construct(private ITimeFactory $timeFactory,
private IManager $calendarManager,
private InvitationResponseServer $server,
private IL10N $l10n,
private FreeBusyGenerator $generator){}
public function processCalendarAvailability(User $user, ?string $availability): ?Status {
$userId = $user->getUID();
$email = $user->getEMailAddress();
if($email === null) {
return null;
}
$server = $this->server->getServer();
/** @var SchedulePlugin $schedulingPlugin */
$schedulingPlugin = $server->getPlugin('caldav-schedule');
$caldavNS = '{'.$schedulingPlugin::NS_CALDAV.'}';
/** @var AclPlugin $aclPlugin */
$aclPlugin = $server->getPlugin('acl');
if ('mailto:' === substr($email, 0, 7)) {
$email = substr($email, 7);
}
$result = $aclPlugin->principalSearch(
['{http://sabredav.org/ns}email-address' => $email],
[
'{DAV:}principal-URL',
$caldavNS.'calendar-home-set',
$caldavNS.'schedule-inbox-URL',
'{http://sabredav.org/ns}email-address',
]
);
if (!count($result) || !isset($result[0][200][$caldavNS.'schedule-inbox-URL'])) {
return null;
}
$inboxUrl = $result[0][200][$caldavNS.'schedule-inbox-URL']->getHref();
// Do we have permission?
try {
$aclPlugin->checkPrivileges($inboxUrl, $caldavNS.'schedule-query-freebusy');
} catch (NeedPrivileges | NotAuthenticated $exception) {
return null;
}
$now = $this->timeFactory->now();
$calendarTimeZone = $now->getTimezone();
$calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId);
if(empty($calendars)) {
return null;
}
$query = $this->calendarManager->newQuery('principals/users/' . $userId);
foreach ($calendars as $calendarObject) {
// We can only work with a calendar if it exposes its scheduling information
if (!$calendarObject instanceof CalendarImpl) {
continue;
}
$sct = $calendarObject->getSchedulingTransparency();
if ($sct !== null && ScheduleCalendarTransp::TRANSPARENT == strtolower($sct->getValue())) {
// If a calendar is marked as 'transparent', it means we must
// ignore it for free-busy purposes.
continue;
}
/** @var Component\VTimeZone|null $ctz */
$ctz = $calendarObject->getSchedulingTimezone();
if ($ctz !== null) {
$calendarTimeZone = $ctz->getTimeZone();
}
$query->addSearchCalendar($calendarObject->getUri());
}
$calendarEvents = [];
$dtStart = $now;
$dtEnd = \DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime('+10 minutes'));
// Only query the calendars when there's any to search
if($query instanceof CalendarQuery && !empty($query->getCalendarUris())) {
// Query the next hour
$query->setTimerangeStart($dtStart);
$query->setTimerangeEnd($dtEnd);
$calendarEvents = $this->calendarManager->searchForPrincipal($query);
}
// @todo we can cache that
if(empty($availability) && empty($calendarEvents)) {
// No availability settings and no calendar events, we can stop here
return null;
}
$calendar = $this->generator->getVCalendar();
foreach ($calendarEvents as $calendarEvent) {
$vEvent = new VEvent($calendar, 'VEVENT');
foreach($calendarEvent['objects'] as $component) {
foreach ($component as $key => $value) {
$vEvent->add($key, $value[0]);
}
}
$calendar->add($vEvent);
}
$calendar->METHOD = 'REQUEST';
$this->generator->setObjects($calendar);
$this->generator->setTimeRange($dtStart, $dtEnd);
$this->generator->setTimeZone($calendarTimeZone);
if (!empty($availability)) {
$this->generator->setVAvailability(
Reader::read(
$availability
)
);
}
// Generate the intersection of VAVILABILITY and all VEVENTS in all calendars
$result = $this->generator->getResult();
if (!isset($result->VFREEBUSY)) {
return null;
}
/** @var Component $freeBusyComponent */
$freeBusyComponent = $result->VFREEBUSY;
$freeBusyProperties = $freeBusyComponent->select('FREEBUSY');
// If there is no FreeBusy property, the time-range is empty and available
// so set the status to online as otherwise we will never recover from a BUSY status
if (count($freeBusyProperties) === 0) {
return new Status(IUserStatus::ONLINE);
}
/** @var Property $freeBusyProperty */
$freeBusyProperty = $freeBusyProperties[0];
if (!$freeBusyProperty->offsetExists('FBTYPE')) {
// If there is no FBTYPE, it means it's busy from a regular event
return new Status(IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY);
}
// If we can't deal with the FBTYPE (custom properties are a possibility)
// we should ignore it and leave the current status
$fbTypeParameter = $freeBusyProperty->offsetGet('FBTYPE');
if (!($fbTypeParameter instanceof Parameter)) {
return null;
}
$fbType = $fbTypeParameter->getValue();
switch ($fbType) {
case 'BUSY':
return new Status(IUserStatus::BUSY, IUserStatus::MESSAGE_CALENDAR_BUSY, $this->l10n->t('In a meeting'));
case 'BUSY-UNAVAILABLE':
return new Status(IUserStatus::AWAY, IUserStatus::MESSAGE_AVAILABILITY);
case 'BUSY-TENTATIVE':
return new Status(IUserStatus::AWAY, IUserStatus::MESSAGE_CALENDAR_BUSY_TENTATIVE);
default:
return null;
}
}
}

1508
apps/dav/tests/unit/CalDAV/Status/StatusServiceTest.php
File diff suppressed because it is too large
View File

20
apps/user_status/lib/Db/UserStatusMapper.php

@ -26,6 +26,7 @@ declare(strict_types=1);
namespace OCA\UserStatus\Db;
use Sabre\CalDAV\Schedule\Plugin;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
@ -210,4 +211,23 @@ class UserStatusMapper extends QBMapper {
$qb->executeStatement();
}
public function getAvailabilityFromPropertiesTable(string $userId): ?string {
$propertyPath = 'calendars/' . $userId . '/inbox';
$propertyName = '{' . Plugin::NS_CALDAV . '}calendar-availability';
$query = $this->db->getQueryBuilder();
$query->select('propertyvalue')
->from('properties')
->where($query->expr()->eq('userid', $query->createNamedParameter($userId)))
->andWhere($query->expr()->eq('propertypath', $query->createNamedParameter($propertyPath)))
->andWhere($query->expr()->eq('propertyname', $query->createNamedParameter($propertyName)))
->setMaxResults(1);
$result = $query->executeQuery();
$property = $result->fetchOne();
$result->closeCursor();
return ($property === false ? null : $property);
}
}

2
apps/user_status/lib/Listener/UserLiveStatusListener.php

@ -74,7 +74,7 @@ class UserLiveStatusListener implements IEventListener {
$userStatus->setIsUserDefined(false);
}
// If the status is user-defined and one of the persistent statuses, we
// 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)) {

2
apps/user_status/lib/Service/PredefinedStatusService.php

@ -202,6 +202,8 @@ class PredefinedStatusService {
self::REMOTE_WORK,
IUserStatus::MESSAGE_CALL,
IUserStatus::MESSAGE_AVAILABILITY,
IUserStatus::MESSAGE_CALENDAR_BUSY,
IUserStatus::MESSAGE_CALENDAR_BUSY_TENTATIVE,
], true);
}
}

140
apps/user_status/lib/Service/StatusService.php

@ -7,6 +7,7 @@ declare(strict_types=1);
*
* @author Georg Ehrke <oc.list@georgehrke.com>
* @author Joas Schilling <coding@schilljs.com>
* @author Anna Larch <anna.larch@gmx.net>
*
* @license GNU AGPL version 3 or any later version
*
@ -26,6 +27,8 @@ declare(strict_types=1);
*/
namespace OCA\UserStatus\Service;
use OCA\DAV\CalDAV\Status\Status as CalendarStatus;
use OCA\DAV\CalDAV\Status\StatusService as CalendarStatusService;
use OCA\UserStatus\Db\UserStatus;
use OCA\UserStatus\Db\UserStatusMapper;
use OCA\UserStatus\Exception\InvalidClearAtException;
@ -35,10 +38,13 @@ use OCA\UserStatus\Exception\InvalidStatusTypeException;
use OCA\UserStatus\Exception\StatusMessageTooLongException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Calendar\ISchedulingInformation;
use OCP\DB\Exception;
use OCP\IConfig;
use OCP\IEmojiHelper;
use OCP\IUserManager;
use OCP\UserStatus\IUserStatus;
use function in_array;
/**
* Class StatusService
@ -46,26 +52,9 @@ use OCP\UserStatus\IUserStatus;
* @package OCA\UserStatus\Service
*/
class StatusService {
/** @var UserStatusMapper */
private $mapper;
/** @var ITimeFactory */
private $timeFactory;
/** @var PredefinedStatusService */
private $predefinedStatusService;
private IEmojiHelper $emojiHelper;
/** @var bool */
private $shareeEnumeration;
/** @var bool */
private $shareeEnumerationInGroupOnly;
/** @var bool */
private $shareeEnumerationPhone;
private bool $shareeEnumeration;
private bool $shareeEnumerationInGroupOnly;
private bool $shareeEnumerationPhone;
/**
* List of priorities ordered by their priority
@ -74,6 +63,7 @@ class StatusService {
IUserStatus::ONLINE,
IUserStatus::AWAY,
IUserStatus::DND,
IUserStatus::BUSY,
IUserStatus::INVISIBLE,
IUserStatus::OFFLINE,
];
@ -84,6 +74,7 @@ class StatusService {
*/
public const PERSISTENT_STATUSES = [
IUserStatus::AWAY,
IUserStatus::BUSY,
IUserStatus::DND,
IUserStatus::INVISIBLE,
];
@ -94,18 +85,16 @@ class StatusService {
/** @var int */
public const MAXIMUM_MESSAGE_LENGTH = 80;
public function __construct(UserStatusMapper $mapper,
ITimeFactory $timeFactory,
PredefinedStatusService $defaultStatusService,
IEmojiHelper $emojiHelper,
IConfig $config) {
$this->mapper = $mapper;
$this->timeFactory = $timeFactory;
$this->predefinedStatusService = $defaultStatusService;
$this->emojiHelper = $emojiHelper;
$this->shareeEnumeration = $config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
$this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
$this->shareeEnumerationPhone = $this->shareeEnumeration && $config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
public function __construct(private UserStatusMapper $mapper,
private ITimeFactory $timeFactory,
private PredefinedStatusService $predefinedStatusService,
private IEmojiHelper $emojiHelper,
private IConfig $config,
private IUserManager $userManager,
private CalendarStatusService $calendarStatusService) {
$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';
}
/**
@ -149,8 +138,37 @@ class StatusService {
* @return UserStatus
* @throws DoesNotExistException
*/
public function findByUserId(string $userId):UserStatus {
return $this->processStatus($this->mapper->findByUserId($userId));
public function findByUserId(string $userId): UserStatus {
$userStatus = $this->mapper->findByUserId($userId);
// 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 $this->processStatus($userStatus);
}
$calendarStatus = $this->getCalendarStatus($userId);
// We found no status from the calendar, proceed with the existing status
if($calendarStatus === null) {
return $this->processStatus($userStatus);
}
// if we have the same status result for the calendar and the current status,
// and a custom message to boot, we leave the existing status alone
// as to not overwrite a custom message / emoji
if($userStatus->getIsUserDefined()
&& $calendarStatus->getStatus() === $userStatus->getStatus()
&& !empty($userStatus->getCustomMessage())) {
return $this->processStatus($userStatus);
}
// If the new status is null, there's already an identical status in place
$newUserStatus = $this->setUserStatus($userId,
$calendarStatus->getStatus(),
$calendarStatus->getMessage() ?? IUserStatus::MESSAGE_AVAILABILITY,
true,
$calendarStatus->getCustomMessage() ?? '');
return $newUserStatus === null ? $this->processStatus($userStatus) : $this->processStatus($newUserStatus);
}
/**
@ -183,9 +201,12 @@ class StatusService {
}
// Check if status-type is valid
if (!\in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
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();
}
@ -255,11 +276,12 @@ class StatusService {
* @throws InvalidMessageIdException
*/
public function setUserStatus(string $userId,
string $status,
string $messageId,
bool $createBackup): void {
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)) {
if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
}
@ -269,7 +291,7 @@ class StatusService {
if ($createBackup) {
if ($this->backupCurrentStatus($userId) === false) {
return; // Already a status set automatically => abort.
return null; // Already a status set automatically => abort.
}
// If we just created the backup
@ -290,15 +312,14 @@ class StatusService {
$userStatus->setIsBackup(false);
$userStatus->setMessageId($messageId);
$userStatus->setCustomIcon(null);
$userStatus->setCustomMessage(null);
$userStatus->setCustomMessage($customMessage);
$userStatus->setClearAt(null);
$userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
if ($userStatus->getId() !== null) {
$this->mapper->update($userStatus);
return;
return $this->mapper->update($userStatus);
}
$this->mapper->insert($userStatus);
return $this->mapper->insert($userStatus);
}
/**
@ -561,4 +582,35 @@ class StatusService {
// For users that matched restore the previous status
$this->mapper->restoreBackupStatuses($restoreIds);
}
/**
* Calculate a users' status according to their availabilit settings and their calendar
* events
*
* There are 4 predefined types of FBTYPE - 'FREE', 'BUSY', 'BUSY-UNAVAILABLE', 'BUSY-TENTATIVE',
* but 'X-' properties are possible
*
* @link https://icalendar.org/iCalendar-RFC-5545/3-2-9-free-busy-time-type.html
*
* The status will be changed for types
* - 'BUSY'
* - 'BUSY-UNAVAILABLE' (ex.: when a VAVILABILITY setting is in effect)
* - 'BUSY-TENTATIVE' (ex.: an event has been accepted tentatively)
* and all FREEBUSY components without a type (implicitly a 'BUSY' status)
*
* 'X-' properties are not handled for now
*
* @param string $userId
* @return CalendarStatus|null
*/
public function getCalendarStatus(string $userId): ?CalendarStatus {
$user = $this->userManager->get($userId);
if ($user === null) {
return null;
}
$availability = $this->mapper->getAvailabilityFromPropertiesTable($userId);
return $this->calendarStatusService->processCalendarAvailability($user, $availability);
}
}

2
apps/user_status/src/mixins/OnlineStatusMixin.js

@ -52,6 +52,7 @@ export default {
return this.$t('user_status', 'Online')
case 'away':
case 'busy':
return this.$t('user_status', 'Away')
case 'dnd':
@ -79,6 +80,7 @@ export default {
return 'icon-user-status-online'
case 'away':
case 'busy':
return 'icon-user-status-away'
case 'dnd':

345
apps/user_status/tests/Unit/Service/StatusServiceTest.php

@ -28,6 +28,9 @@ namespace OCA\UserStatus\Tests\Service;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use OC\DB\Exceptions\DbalException;
use OC\User\User;
use OCA\DAV\CalDAV\Status\Status;
use OCA\DAV\CalDAV\Status\StatusService as CalendarStatusService;
use OCA\UserStatus\Db\UserStatus;
use OCA\UserStatus\Db\UserStatusMapper;
use OCA\UserStatus\Exception\InvalidClearAtException;
@ -40,30 +43,40 @@ use OCA\UserStatus\Service\StatusService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\Exception;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig;
use OCP\IEmojiHelper;
use OCP\IUser;
use OCP\IUserBackend;
use OCP\IUserManager;
use OCP\UserStatus\IUserStatus;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class StatusServiceTest extends TestCase {
/** @var UserStatusMapper|\PHPUnit\Framework\MockObject\MockObject */
/** @var UserStatusMapper|MockObject */
private $mapper;
/** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */
/** @var ITimeFactory|MockObject */
private $timeFactory;
/** @var PredefinedStatusService|\PHPUnit\Framework\MockObject\MockObject */
/** @var PredefinedStatusService|MockObject */
private $predefinedStatusService;
/** @var IEmojiHelper|\PHPUnit\Framework\MockObject\MockObject */
/** @var IEmojiHelper|MockObject */
private $emojiHelper;
/** @var IConfig|\PHPUnit\Framework\MockObject\MockObject */
/** @var IConfig|MockObject */
private $config;
/** @var StatusService */
private $service;
/** @var IUserManager|MockObject */
private $userManager;
/** @var CalendarStatusService|MockObject */
private $calendarStatusService;
private StatusService $service;
protected function setUp(): void {
parent::setUp();
@ -72,6 +85,8 @@ class StatusServiceTest extends TestCase {
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->predefinedStatusService = $this->createMock(PredefinedStatusService::class);
$this->emojiHelper = $this->createMock(IEmojiHelper::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->calendarStatusService = $this->createMock(CalendarStatusService::class);
$this->config = $this->createMock(IConfig::class);
@ -85,7 +100,10 @@ class StatusServiceTest extends TestCase {
$this->timeFactory,
$this->predefinedStatusService,
$this->emojiHelper,
$this->config);
$this->config,
$this->userManager,
$this->calendarStatusService,
);
}
public function testFindAll(): void {
@ -139,7 +157,10 @@ class StatusServiceTest extends TestCase {
$this->timeFactory,
$this->predefinedStatusService,
$this->emojiHelper,
$this->config);
$this->config,
$this->userManager,
$this->calendarStatusService,
);
$this->assertEquals([], $this->service->findAllRecentStatusChanges(20, 50));
@ -156,21 +177,14 @@ class StatusServiceTest extends TestCase {
$this->timeFactory,
$this->predefinedStatusService,
$this->emojiHelper,
$this->config);
$this->config,
$this->userManager,
$this->calendarStatusService,
);
$this->assertEquals([], $this->service->findAllRecentStatusChanges(20, 50));
}
public function testFindByUserId(): void {
$status = $this->createMock(UserStatus::class);
$this->mapper->expects($this->once())
->method('findByUserId')
->with('john.doe')
->willReturn($status);
$this->assertEquals($status, $this->service->findByUserId('john.doe'));
}
public function testFindByUserIdDoesNotExist(): void {
$this->mapper->expects($this->once())
->method('findByUserId')
@ -825,4 +839,295 @@ class StatusServiceTest extends TestCase {
$this->service->revertMultipleUserStatus(['john', 'nobackup', 'backuponly', 'nobackupanddnd'], 'call');
}
public function testCalendarAvailabilityNoUser(): void {
$userId = 'admin';
$this->userManager->expects(self::once())
->method('get')
->with($userId)
->willReturn(null);
$this->mapper->expects(self::never())
->method('getAvailabilityFromPropertiesTable');
$this->calendarStatusService->expects(self::never())
->method('processCalendarAvailability');
$this->service->getCalendarStatus($userId);
}
public function testCalendarAvailabilityNoVavailablility(): void {
$user = $this->createConfiguredMock(IUser::class, [
'getUID' => 'admin',
'getEMailAddress' => 'test@test.com',
]);
$this->userManager->expects(self::once())
->method('get')
->with($user->getUID())
->willReturn($user);
$this->mapper->expects(self::once())
->method('getAvailabilityFromPropertiesTable')
->willReturn('');
$this->calendarStatusService->expects(self::once())
->method('processCalendarAvailability')
->with($user, '')
->willReturn(null);
$this->service->getCalendarStatus($user->getUID());
}
public function testCalendarAvailabilityVavailablilityAvailable(): void {
$user = $this->createConfiguredMock(IUser::class, [
'getUID' => 'admin',
'getEMailAddress' => 'test@test.com',
]);
$vavailability = <<<EOF
BEGIN:VCALENDAR
PRODID:Nextcloud DAV app
BEGIN:VTIMEZONE
TZID:Europe/Vienna
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VAVAILABILITY
BEGIN:AVAILABLE
DTSTART;TZID=Europe/Vienna:20231025T000000
DTEND;TZID=Europe/Vienna:20231025T235900
UID:d866782e-e003-4906-9ece-303f270a2c6b
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU
END:AVAILABLE
END:VAVAILABILITY
END:VCALENDAR
EOF;
$status = new Status(IUserStatus::AWAY);
$this->userManager->expects(self::once())
->method('get')
->with($user->getUID())
->willReturn($user);
$this->mapper->expects(self::once())
->method('getAvailabilityFromPropertiesTable')
->willReturn($vavailability);
$this->calendarStatusService->expects(self::once())
->method('processCalendarAvailability')
->with($user, $vavailability)
->willReturn($status);
$this->service->getCalendarStatus($user->getUID());
}
public function testCalendarAvailabilityVavailablilityUpdate(): void {
$user = $this->createConfiguredMock(IUser::class, [
'getUID' => 'admin',
'getEMailAddress' => 'test@test.com',
]);
$calDavStatus = new Status(IUserStatus::BUSY, 'meeting', 'In a meeting');
$vavailability = <<<EOF
BEGIN:VCALENDAR
PRODID:Nextcloud DAV app
BEGIN:VTIMEZONE
TZID:Europe/Vienna
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VAVAILABILITY
BEGIN:AVAILABLE
DTSTART;TZID=Europe/Vienna:20231025T000000
DTEND;TZID=Europe/Vienna:20231025T235900
UID:d866782e-e003-4906-9ece-303f270a2c6b
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU
END:AVAILABLE
END:VAVAILABILITY
END:VCALENDAR
EOF;
$this->userManager->expects(self::once())
->method('get')
->with($user->getUID())
->willReturn($user);
$this->mapper->expects(self::once())
->method('getAvailabilityFromPropertiesTable')
->willReturn($vavailability);
$this->calendarStatusService->expects(self::once())
->method('processCalendarAvailability')
->with($user, $vavailability)
->willReturn($calDavStatus);
$this->service->getCalendarStatus($user->getUID());
}
public function testFindByUserIdUserDefinedAndPersistent(): void {
$status = new UserStatus();
$status->setIsUserDefined(true);
$status->setStatus(IUserStatus::DND);
$this->mapper->expects($this->once())
->method('findByUserId')
->with('admin')
->willReturn($status);
$this->mapper->expects(self::never())
->method('getAvailabilityFromPropertiesTable');
$this->calendarStatusService->expects(self::never())
->method('processCalendarAvailability');
$this->assertEquals($status, $this->service->findByUserId('admin'));
}
public function testFindByUserIdUserDefinedNoCalStatus(): void {
$user = $this->createConfiguredMock(IUser::class, [
'getUID' => 'admin',
'getEMailAddress' => 'test@test.com',
]);
$status = new UserStatus();
$status->setIsUserDefined(true);
$status->setStatus(IUserStatus::ONLINE);
$this->mapper->expects($this->once())
->method('findByUserId')
->with($user->getUID())
->willReturn($status);
$this->userManager->expects(self::once())
->method('get')
->willReturn($user);
$this->mapper->expects(self::once())
->method('getAvailabilityFromPropertiesTable')
->willReturn('');
$this->calendarStatusService->expects(self::once())
->method('processCalendarAvailability')
->with($user, '')
->willReturn(null);
$this->assertEquals($status, $this->service->findByUserId('admin'));
}
public function testFindByUserIdUserDefinedCalStatusIdentical(): void {
$user = $this->createConfiguredMock(IUser::class, [
'getUID' => 'admin',
'getEMailAddress' => 'test@test.com',
]);
$calDavStatus = new Status(IUserStatus::ONLINE);
$userStatus = new UserStatus();
$userStatus->setStatus(IUserStatus::ONLINE);
$userStatus->setIsUserDefined(true);
$userStatus->setCustomMessage('Test');
$this->mapper->expects(self::once())
->method('findByUserId')
->with($user->getUID())
->willReturn($userStatus);
$this->userManager->expects(self::once())
->method('get')
->willReturn($user);
$this->mapper->expects(self::once())
->method('getAvailabilityFromPropertiesTable')
->willReturn('');
$this->calendarStatusService->expects(self::once())
->method('processCalendarAvailability')
->with($user, '')
->willReturn($calDavStatus);
$this->assertEquals($userStatus, $this->service->findByUserId('admin'));
}
public function testFindByUserIdUserDefinedCalStatusUpdate(): void {
$user = $this->createConfiguredMock(IUser::class, [
'getUID' => 'admin',
'getEMailAddress' => 'test@test.com',
]);
$calDavStatus = new Status(IUserStatus::BUSY, 'meeting', 'In a meeting');
$oldStatus = new UserStatus();
$oldStatus->setId(42);
$oldStatus->setUserId($user->getUID());
$oldStatus->setStatus(IUserStatus::ONLINE);
$oldStatus->setStatusTimestamp(0);
$oldStatus->setIsUserDefined(true);
$expected = new UserStatus();
$expected->setUserId($user->getUID());
$expected->setStatus(IUserStatus::BUSY);
$expected->setStatusTimestamp(0);
$expected->setIsUserDefined(true);
$expected->setIsBackup(false);
$this->mapper->expects(self::once())
->method('findByUserId')
->with($user->getUID())
->willReturn($oldStatus);
$this->userManager->expects(self::once())
->method('get')
->willReturn($user);
$this->mapper->expects(self::once())
->method('getAvailabilityFromPropertiesTable')
->willReturn('');
$this->mapper->expects(self::once())
->method('createBackupStatus')
->with($user->getUID())
->willReturn(true);
$this->calendarStatusService->expects(self::once())
->method('processCalendarAvailability')
->with($user, '')
->willReturn($calDavStatus);
$this->predefinedStatusService->expects(self::once())
->method('isValidId')
->with($calDavStatus->getMessage())
->willReturn(true);
$this->mapper->expects(self::once())
->method('insert')
->willReturn($expected);
$actual = $this->service->findByUserId('admin');
$this->assertEquals($expected->getStatus(), $actual->getStatus());
$this->assertEquals($expected->getCustomMessage(), $actual->getCustomMessage());
}
public function testFindByUserIdSystemDefined(): void {
$user = $this->createConfiguredMock(IUser::class, [
'getUID' => 'admin',
'getEMailAddress' => 'test@test.com',
]);
$status = new UserStatus();
$status->setIsUserDefined(false);
$status->setStatus(IUserStatus::ONLINE);
$this->mapper->expects($this->once())
->method('findByUserId')
->with($user->getUID())
->willReturn($status);
$this->userManager->expects(self::once())
->method('get')
->willReturn($user);
$this->mapper->expects(self::once())
->method('getAvailabilityFromPropertiesTable')
->willReturn('');
$this->calendarStatusService->expects(self::once())
->method('processCalendarAvailability')
->with($user, '')
->willReturn(null);
$this->assertEquals($status, $this->service->findByUserId('admin'));
}
}

4
dist/user_status-menu.js
File diff suppressed because it is too large
View File

2
dist/user_status-menu.js.map
File diff suppressed because it is too large
View File

18
lib/public/UserStatus/IUserStatus.php

@ -51,6 +51,12 @@ interface IUserStatus {
*/
public const DND = 'dnd';
/**
* @var string
* @since 28.0.0
*/
public const BUSY = 'busy';
/**
* @var string
* @since 20.0.0
@ -75,6 +81,18 @@ interface IUserStatus {
*/
public const MESSAGE_AVAILABILITY = 'availability';
/**
* @var string
* @since 28.0.0
*/
public const MESSAGE_CALENDAR_BUSY = 'meeting';
/**
* @var string
* @since 28.0.0
*/
public const MESSAGE_CALENDAR_BUSY_TENTATIVE = 'busy-tentative';
/**
* Get the user this status is connected to
*

Loading…
Cancel
Save