diff options
Diffstat (limited to 'lib/private/Contacts/ContactsMenu')
9 files changed, 587 insertions, 452 deletions
diff --git a/lib/private/Contacts/ContactsMenu/ActionFactory.php b/lib/private/Contacts/ContactsMenu/ActionFactory.php index 0cdd1245b31..40037598d49 100644 --- a/lib/private/Contacts/ContactsMenu/ActionFactory.php +++ b/lib/private/Contacts/ContactsMenu/ActionFactory.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Contacts\ContactsMenu; use OC\Contacts\ContactsMenu\Actions\LinkAction; @@ -28,28 +11,22 @@ use OCP\Contacts\ContactsMenu\IActionFactory; use OCP\Contacts\ContactsMenu\ILinkAction; class ActionFactory implements IActionFactory { - /** - * @param string $icon - * @param string $name - * @param string $href - * @return ILinkAction + * {@inheritDoc} */ - public function newLinkAction($icon, $name, $href) { + public function newLinkAction(string $icon, string $name, string $href, string $appId = ''): ILinkAction { $action = new LinkAction(); $action->setName($name); $action->setIcon($icon); $action->setHref($href); + $action->setAppId($appId); return $action; } /** - * @param string $icon - * @param string $name - * @param string $email - * @return ILinkAction + * {@inheritDoc} */ - public function newEMailAction($icon, $name, $email) { - return $this->newLinkAction($icon, $name, 'mailto:' . $email); + public function newEMailAction(string $icon, string $name, string $email, string $appId = ''): ILinkAction { + return $this->newLinkAction($icon, $name, 'mailto:' . $email, $appId); } } diff --git a/lib/private/Contacts/ContactsMenu/ActionProviderStore.php b/lib/private/Contacts/ContactsMenu/ActionProviderStore.php index 5513dd06362..b760de03a04 100644 --- a/lib/private/Contacts/ContactsMenu/ActionProviderStore.php +++ b/lib/private/Contacts/ContactsMenu/ActionProviderStore.php @@ -1,24 +1,10 @@ <?php + +declare(strict_types=1); + /** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Contacts\ContactsMenu; @@ -26,54 +12,53 @@ namespace OC\Contacts\ContactsMenu; use Exception; use OC\App\AppManager; use OC\Contacts\ContactsMenu\Providers\EMailProvider; +use OC\Contacts\ContactsMenu\Providers\LocalTimeProvider; +use OC\Contacts\ContactsMenu\Providers\ProfileProvider; use OCP\AppFramework\QueryException; +use OCP\Contacts\ContactsMenu\IBulkProvider; use OCP\Contacts\ContactsMenu\IProvider; -use OCP\ILogger; use OCP\IServerContainer; use OCP\IUser; +use Psr\Log\LoggerInterface; class ActionProviderStore { - - /** @var IServerContainer */ - private $serverContainer; - - /** @var AppManager */ - private $appManager; - - /** @var ILogger */ - private $logger; - - /** - * @param IServerContainer $serverContainer - * @param AppManager $appManager - * @param ILogger $logger - */ - public function __construct(IServerContainer $serverContainer, AppManager $appManager, ILogger $logger) { - $this->serverContainer = $serverContainer; - $this->appManager = $appManager; - $this->logger = $logger; + public function __construct( + private IServerContainer $serverContainer, + private AppManager $appManager, + private LoggerInterface $logger, + ) { } /** - * @param IUser $user - * @return IProvider[] + * @return list<IProvider|IBulkProvider> * @throws Exception */ - public function getProviders(IUser $user) { + public function getProviders(IUser $user): array { $appClasses = $this->getAppProviderClasses($user); $providerClasses = $this->getServerProviderClasses(); $allClasses = array_merge($providerClasses, $appClasses); + /** @var list<IProvider|IBulkProvider> $providers */ $providers = []; foreach ($allClasses as $class) { try { - $providers[] = $this->serverContainer->query($class); + $provider = $this->serverContainer->get($class); + if ($provider instanceof IProvider || $provider instanceof IBulkProvider) { + $providers[] = $provider; + } else { + $this->logger->warning('Ignoring invalid contacts menu provider', [ + 'class' => $class, + ]); + } } catch (QueryException $ex) { - $this->logger->logException($ex, [ - 'message' => "Could not load contacts menu action provider $class", - 'app' => 'core', - ]); - throw new Exception("Could not load contacts menu action provider"); + $this->logger->error( + 'Could not load contacts menu action provider ' . $class, + [ + 'app' => 'core', + 'exception' => $ex, + ] + ); + throw new Exception('Could not load contacts menu action provider'); } } @@ -83,21 +68,22 @@ class ActionProviderStore { /** * @return string[] */ - private function getServerProviderClasses() { + private function getServerProviderClasses(): array { return [ + ProfileProvider::class, + LocalTimeProvider::class, EMailProvider::class, ]; } /** - * @param IUser $user * @return string[] */ - private function getAppProviderClasses(IUser $user) { + private function getAppProviderClasses(IUser $user): array { return array_reduce($this->appManager->getEnabledAppsForUser($user), function ($all, $appId) { $info = $this->appManager->getAppInfo($appId); - if (!isset($info['contactsmenu']) || !isset($info['contactsmenu'])) { + if (!isset($info['contactsmenu'])) { // Nothing to add return $all; } diff --git a/lib/private/Contacts/ContactsMenu/Actions/LinkAction.php b/lib/private/Contacts/ContactsMenu/Actions/LinkAction.php index eac169afb77..cdaf9308bfc 100644 --- a/lib/private/Contacts/ContactsMenu/Actions/LinkAction.php +++ b/lib/private/Contacts/ContactsMenu/Actions/LinkAction.php @@ -1,101 +1,74 @@ <?php + /** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Contacts\ContactsMenu\Actions; use OCP\Contacts\ContactsMenu\ILinkAction; class LinkAction implements ILinkAction { - - /** @var string */ - private $icon; - - /** @var string */ - private $name; - - /** @var string */ - private $href; - - /** @var int */ - private $priority = 10; + private string $icon = ''; + private string $name = ''; + private string $href = ''; + private int $priority = 10; + private string $appId = ''; /** * @param string $icon absolute URI to an icon */ - public function setIcon($icon) { + public function setIcon(string $icon): void { $this->icon = $icon; } - /** - * @param string $name - */ - public function setName($name) { + public function setName(string $name): void { $this->name = $name; } - /** - * @return string - */ - public function getName() { + public function getName(): string { return $this->name; } - /** - * @param int $priority - */ - public function setPriority($priority) { + public function setPriority(int $priority): void { $this->priority = $priority; } - /** - * @return int - */ - public function getPriority() { + public function getPriority(): int { return $this->priority; } + public function setHref(string $href): void { + $this->href = $href; + } + + public function getHref(): string { + return $this->href; + } + /** - * @param string $href + * @since 23.0.0 */ - public function setHref($href) { - $this->href = $href; + public function setAppId(string $appId): void { + $this->appId = $appId; } /** - * @return string + * @since 23.0.0 */ - public function getHref() { - return $this->href; + public function getAppId(): string { + return $this->appId; } /** - * @return array + * @return array{title: string, icon: string, hyperlink: string, appId: string} */ - public function jsonSerialize() { + public function jsonSerialize(): array { return [ 'title' => $this->name, 'icon' => $this->icon, 'hyperlink' => $this->href, + 'appId' => $this->appId, ]; } } diff --git a/lib/private/Contacts/ContactsMenu/ContactsStore.php b/lib/private/Contacts/ContactsMenu/ContactsStore.php index e2bd7edc63d..5fa25512c97 100644 --- a/lib/private/Contacts/ContactsMenu/ContactsStore.php +++ b/lib/private/Contacts/ContactsMenu/ContactsStore.php @@ -1,100 +1,142 @@ <?php + /** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * @copyright 2017 Lukas Reschke <lukas@statuscode.ch> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tobia De Koninck <tobia@ledfan.be> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Contacts\ContactsMenu; +use OC\KnownUser\KnownUserService; +use OC\Profile\ProfileManager; +use OCA\UserStatus\Db\UserStatus; +use OCA\UserStatus\Service\StatusService; use OCP\Contacts\ContactsMenu\IContactsStore; use OCP\Contacts\ContactsMenu\IEntry; use OCP\Contacts\IManager; use OCP\IConfig; use OCP\IGroupManager; +use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; +use OCP\L10N\IFactory as IL10NFactory; +use function array_column; +use function array_fill_keys; +use function array_filter; +use function array_key_exists; +use function array_merge; +use function count; class ContactsStore implements IContactsStore { - - /** @var IManager */ - private $contactsManager; - - /** @var IConfig */ - private $config; - - /** @var IUserManager */ - private $userManager; - - /** @var IGroupManager */ - private $groupManager; - - /** - * @param IManager $contactsManager - * @param IConfig $config - * @param IUserManager $userManager - * @param IGroupManager $groupManager - */ - public function __construct(IManager $contactsManager, - IConfig $config, - IUserManager $userManager, - IGroupManager $groupManager) { - $this->contactsManager = $contactsManager; - $this->config = $config; - $this->userManager = $userManager; - $this->groupManager = $groupManager; + public function __construct( + private IManager $contactsManager, + private ?StatusService $userStatusService, + private IConfig $config, + private ProfileManager $profileManager, + private IUserManager $userManager, + private IURLGenerator $urlGenerator, + private IGroupManager $groupManager, + private KnownUserService $knownUserService, + private IL10NFactory $l10nFactory, + ) { } /** - * @param IUser $user - * @param string|null $filter * @return IEntry[] */ - public function getContacts(IUser $user, $filter, ?int $limit = null, ?int $offset = null) { - $options = []; + public function getContacts(IUser $user, ?string $filter, ?int $limit = null, ?int $offset = null): array { + $options = [ + 'enumeration' => $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes', + 'fullmatch' => $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') === 'yes', + ]; if ($limit !== null) { $options['limit'] = $limit; } if ($offset !== null) { $options['offset'] = $offset; } + // Status integration only works without pagination and filters + if ($offset === null && ($filter === null || $filter === '')) { + $recentStatuses = $this->userStatusService?->findAllRecentStatusChanges($limit, $offset) ?? []; + } else { + $recentStatuses = []; + } - $allContacts = $this->contactsManager->search( - $filter ?: '', - [ - 'FN', - 'EMAIL' - ], - $options - ); + // Search by status if there is no filter and statuses are available + if (!empty($recentStatuses)) { + $allContacts = array_filter(array_map(function (UserStatus $userStatus) use ($options) { + // UID is ambiguous with federation. We have to use the federated cloud ID to an exact match of + // A local user + $user = $this->userManager->get($userStatus->getUserId()); + if ($user === null) { + return null; + } + + $contact = $this->contactsManager->search( + $user->getCloudId(), + [ + 'CLOUD', + ], + array_merge( + $options, + [ + 'limit' => 1, + 'offset' => 0, + ], + ), + )[0] ?? null; + if ($contact !== null) { + $contact[Entry::PROPERTY_STATUS_MESSAGE_TIMESTAMP] = $userStatus->getStatusMessageTimestamp(); + } + return $contact; + }, $recentStatuses)); + if ($limit !== null && count($allContacts) < $limit) { + // More contacts were requested + $fromContacts = $this->contactsManager->search( + $filter ?? '', + [ + 'FN', + 'EMAIL' + ], + array_merge( + $options, + [ + 'limit' => $limit - count($allContacts), + ], + ), + ); + + // Create hash map of all status contacts + $existing = array_fill_keys(array_column($allContacts, 'URI'), null); + // Append the ones that are new + $allContacts = array_merge( + $allContacts, + array_filter($fromContacts, fn (array $contact): bool => !array_key_exists($contact['URI'], $existing)) + ); + } + } else { + $allContacts = $this->contactsManager->search( + $filter ?? '', + [ + 'FN', + 'EMAIL' + ], + $options + ); + } + + $userId = $user->getUID(); + $contacts = array_filter($allContacts, function ($contact) use ($userId) { + // When searching for multiple results, we strip out the current user + if (array_key_exists('UID', $contact)) { + return $contact['UID'] !== $userId; + } + return true; + }); $entries = array_map(function (array $contact) { return $this->contactArrayToEntry($contact); - }, $allContacts); + }, $contacts); return $this->filterContacts( $user, $entries, @@ -103,99 +145,141 @@ class ContactsStore implements IContactsStore { } /** - * Filters the contacts. Applies 3 filters: - * 1. filter the current user - * 2. if the `shareapi_allow_share_dialog_user_enumeration` config option is + * Filters the contacts. Applied filters: + * 1. if the `shareapi_allow_share_dialog_user_enumeration` config option is * enabled it will filter all local users - * 3. if the `shareapi_exclude_groups` config option is enabled and the + * 2. if the `shareapi_exclude_groups` config option is enabled and the * current user is in an excluded group it will filter all local users. - * 4. if the `shareapi_only_share_with_group_members` config option is - * enabled it will filter all users which doens't have a common group + * 3. if the `shareapi_only_share_with_group_members` config option is + * enabled it will filter all users which doesn't have a common group * with the current user. + * If enabled, the 'shareapi_only_share_with_group_members_exclude_group_list' + * config option may specify some groups excluded from the principle of + * belonging to the same group. * - * @param IUser $self * @param Entry[] $entries - * @param string $filter * @return Entry[] the filtered contacts */ - private function filterContacts(IUser $self, - array $entries, - $filter) { + private function filterContacts( + IUser $self, + array $entries, + ?string $filter, + ): array { $disallowEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') !== 'yes'; - $restrictEnumeration = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; - $excludedGroups = $this->config->getAppValue('core', 'shareapi_exclude_groups', 'no') === 'yes'; + $restrictEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; + $restrictEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; + $allowEnumerationFullMatch = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') === 'yes'; + $excludeGroups = $this->config->getAppValue('core', 'shareapi_exclude_groups', 'no'); // whether to filter out local users $skipLocal = false; - // whether to filter out all users which doesn't have the same group as the current user - $ownGroupsOnly = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members', 'no') === 'yes' || $restrictEnumeration; + // whether to filter out all users which don't have a common group as the current user + $ownGroupsOnly = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members', 'no') === 'yes'; $selfGroups = $this->groupManager->getUserGroupIds($self); - if ($excludedGroups) { + if ($excludeGroups && $excludeGroups !== 'no') { $excludedGroups = $this->config->getAppValue('core', 'shareapi_exclude_groups_list', ''); $decodedExcludeGroups = json_decode($excludedGroups, true); - $excludeGroupsList = ($decodedExcludeGroups !== null) ? $decodedExcludeGroups : []; + $excludeGroupsList = $decodedExcludeGroups ?? []; - if (count(array_intersect($excludeGroupsList, $selfGroups)) !== 0) { - // a group of the current user is excluded -> filter all local users + if ($excludeGroups != 'allow') { + if (count(array_intersect($excludeGroupsList, $selfGroups)) !== 0) { + // a group of the current user is excluded -> filter all local users + $skipLocal = true; + } + } else { $skipLocal = true; + if (count(array_intersect($excludeGroupsList, $selfGroups)) !== 0) { + // a group of the current user is allowed -> do not filter all local users + $skipLocal = false; + } } } + // ownGroupsOnly : some groups may be excluded + if ($ownGroupsOnly) { + $excludeGroupsFromOwnGroups = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members_exclude_group_list', ''); + $excludeGroupsFromOwnGroupsList = json_decode($excludeGroupsFromOwnGroups, true) ?? []; + $selfGroups = array_diff($selfGroups, $excludeGroupsFromOwnGroupsList); + } + $selfUID = $self->getUID(); - return array_values(array_filter($entries, function (IEntry $entry) use ($self, $skipLocal, $ownGroupsOnly, $selfGroups, $selfUID, $disallowEnumeration, $filter) { - if ($skipLocal && $entry->getProperty('isLocalSystemBook') === true) { - return false; - } + return array_values(array_filter($entries, function (IEntry $entry) use ($skipLocal, $ownGroupsOnly, $selfGroups, $selfUID, $disallowEnumeration, $restrictEnumerationGroup, $restrictEnumerationPhone, $allowEnumerationFullMatch, $filter) { + if ($entry->getProperty('isLocalSystemBook')) { + if ($skipLocal) { + return false; + } - // Prevent enumerating local users - if ($disallowEnumeration && $entry->getProperty('isLocalSystemBook')) { - $filterUser = true; + $checkedCommonGroupAlready = false; - $mailAddresses = $entry->getEMailAddresses(); - foreach ($mailAddresses as $mailAddress) { - if ($mailAddress === $filter) { - $filterUser = false; - break; + // Prevent enumerating local users + if ($disallowEnumeration) { + if (!$allowEnumerationFullMatch) { + return false; } - } - if ($entry->getProperty('UID') && $entry->getProperty('UID') === $filter) { - $filterUser = false; - } + $filterOutUser = true; - if ($filterUser) { - return false; - } - } + $mailAddresses = $entry->getEMailAddresses(); + foreach ($mailAddresses as $mailAddress) { + if ($mailAddress === $filter) { + $filterOutUser = false; + break; + } + } + + if ($entry->getProperty('UID') && $entry->getProperty('UID') === $filter) { + $filterOutUser = false; + } - if ($ownGroupsOnly && $entry->getProperty('isLocalSystemBook') === true) { - $uid = $this->userManager->get($entry->getProperty('UID')); + if ($filterOutUser) { + return false; + } + } elseif ($restrictEnumerationPhone || $restrictEnumerationGroup) { + $canEnumerate = false; + if ($restrictEnumerationPhone) { + $canEnumerate = $this->knownUserService->isKnownToUser($selfUID, $entry->getProperty('UID')); + } - if ($uid === null) { - return false; + if (!$canEnumerate && $restrictEnumerationGroup) { + $user = $this->userManager->get($entry->getProperty('UID')); + + if ($user === null) { + return false; + } + + $contactGroups = $this->groupManager->getUserGroupIds($user); + $canEnumerate = !empty(array_intersect($contactGroups, $selfGroups)); + $checkedCommonGroupAlready = true; + } + + if (!$canEnumerate) { + return false; + } } - $contactGroups = $this->groupManager->getUserGroupIds($uid); - if (count(array_intersect($contactGroups, $selfGroups)) === 0) { - // no groups in common, so shouldn't see the contact - return false; + if ($ownGroupsOnly && !$checkedCommonGroupAlready) { + $user = $this->userManager->get($entry->getProperty('UID')); + + if (!$user instanceof IUser) { + return false; + } + + $contactGroups = $this->groupManager->getUserGroupIds($user); + if (empty(array_intersect($contactGroups, $selfGroups))) { + // no groups in common, so shouldn't see the contact + return false; + } } } - return $entry->getProperty('UID') !== $selfUID; + return true; })); } - /** - * @param IUser $user - * @param integer $shareType - * @param string $shareWith - * @return IEntry|null - */ - public function findOne(IUser $user, $shareType, $shareWith) { + public function findOne(IUser $user, int $shareType, string $shareWith): ?IEntry { switch ($shareType) { case 0: case 6: @@ -208,11 +292,9 @@ class ContactsStore implements IContactsStore { return null; } - $userId = $user->getUID(); - $allContacts = $this->contactsManager->search($shareWith, $filter); - $contacts = array_filter($allContacts, function ($contact) use ($userId) { - return $contact['UID'] !== $userId; - }); + $contacts = $this->contactsManager->search($shareWith, $filter, [ + 'strict_search' => true, + ]); $match = null; foreach ($contacts as $contact) { @@ -243,32 +325,52 @@ class ContactsStore implements IContactsStore { return $match; } - /** - * @param array $contact - * @return Entry - */ - private function contactArrayToEntry(array $contact) { + private function contactArrayToEntry(array $contact): Entry { $entry = new Entry(); - if (isset($contact['id'])) { - $entry->setId($contact['id']); + if (!empty($contact['UID'])) { + $uid = $contact['UID']; + $entry->setId($uid); + $entry->setProperty('isUser', false); + // overloaded usage so leaving as-is for now + if (isset($contact['isLocalSystemBook'])) { + $avatar = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $uid, 'size' => 64]); + $entry->setProperty('isUser', true); + } elseif (!empty($contact['FN'])) { + $avatar = $this->urlGenerator->linkToRouteAbsolute('core.GuestAvatar.getAvatar', ['guestName' => str_replace('/', ' ', $contact['FN']), 'size' => 64]); + } else { + $avatar = $this->urlGenerator->linkToRouteAbsolute('core.GuestAvatar.getAvatar', ['guestName' => str_replace('/', ' ', $uid), 'size' => 64]); + } + $entry->setAvatar($avatar); } - if (isset($contact['FN'])) { + if (!empty($contact['FN'])) { $entry->setFullName($contact['FN']); } - $avatarPrefix = "VALUE=uri:"; - if (isset($contact['PHOTO']) && strpos($contact['PHOTO'], $avatarPrefix) === 0) { + $avatarPrefix = 'VALUE=uri:'; + if (!empty($contact['PHOTO']) && str_starts_with($contact['PHOTO'], $avatarPrefix)) { $entry->setAvatar(substr($contact['PHOTO'], strlen($avatarPrefix))); } - if (isset($contact['EMAIL'])) { + if (!empty($contact['EMAIL'])) { foreach ($contact['EMAIL'] as $email) { $entry->addEMailAddress($email); } } + // Provide profile parameters for core/src/OC/contactsmenu/contact.handlebars template + if (!empty($contact['UID']) && !empty($contact['FN'])) { + $targetUserId = $contact['UID']; + $targetUser = $this->userManager->get($targetUserId); + if (!empty($targetUser)) { + if ($this->profileManager->isProfileEnabled($targetUser)) { + $entry->setProfileTitle($this->l10nFactory->get('lib')->t('View profile')); + $entry->setProfileUrl($this->urlGenerator->linkToRouteAbsolute('profile.ProfilePage.index', ['targetUserId' => $targetUserId])); + } + } + } + // Attach all other properties to the entry too because some // providers might make use of it. $entry->setProperties($contact); diff --git a/lib/private/Contacts/ContactsMenu/Entry.php b/lib/private/Contacts/ContactsMenu/Entry.php index 675d925134b..d4f2dc7bf90 100644 --- a/lib/private/Contacts/ContactsMenu/Entry.php +++ b/lib/private/Contacts/ContactsMenu/Entry.php @@ -1,119 +1,118 @@ <?php + +declare(strict_types=1); + /** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Contacts\ContactsMenu; use OCP\Contacts\ContactsMenu\IAction; use OCP\Contacts\ContactsMenu\IEntry; +use function array_merge; class Entry implements IEntry { + public const PROPERTY_STATUS_MESSAGE_TIMESTAMP = 'statusMessageTimestamp'; /** @var string|int|null */ private $id = null; - /** @var string */ - private $fullName = ''; + private string $fullName = ''; /** @var string[] */ - private $emailAddresses = []; + private array $emailAddresses = []; + + private ?string $avatar = null; - /** @var string|null */ - private $avatar; + private ?string $profileTitle = null; + + private ?string $profileUrl = null; /** @var IAction[] */ - private $actions = []; + private array $actions = []; - /** @var array */ - private $properties = []; + private array $properties = []; - /** - * @param string $id - */ - public function setId($id) { + private ?string $status = null; + private ?string $statusMessage = null; + private ?int $statusMessageTimestamp = null; + private ?string $statusIcon = null; + + public function setId(string $id): void { $this->id = $id; } - /** - * @param string $displayName - */ - public function setFullName($displayName) { + public function setFullName(string $displayName): void { $this->fullName = $displayName; } - /** - * @return string - */ - public function getFullName() { + public function getFullName(): string { return $this->fullName; } - /** - * @param string $address - */ - public function addEMailAddress($address) { + public function addEMailAddress(string $address): void { $this->emailAddresses[] = $address; } /** - * @return string + * @return string[] */ - public function getEMailAddresses() { + public function getEMailAddresses(): array { return $this->emailAddresses; } - /** - * @param string $avatar - */ - public function setAvatar($avatar) { + public function setAvatar(string $avatar): void { $this->avatar = $avatar; } - /** - * @return string - */ - public function getAvatar() { + public function getAvatar(): ?string { return $this->avatar; } - /** - * @param IAction $action - */ - public function addAction(IAction $action) { + public function setProfileTitle(string $profileTitle): void { + $this->profileTitle = $profileTitle; + } + + public function getProfileTitle(): ?string { + return $this->profileTitle; + } + + public function setProfileUrl(string $profileUrl): void { + $this->profileUrl = $profileUrl; + } + + public function getProfileUrl(): ?string { + return $this->profileUrl; + } + + public function addAction(IAction $action): void { $this->actions[] = $action; $this->sortActions(); } + public function setStatus(string $status, + ?string $statusMessage = null, + ?int $statusMessageTimestamp = null, + ?string $icon = null): void { + $this->status = $status; + $this->statusMessage = $statusMessage; + $this->statusMessageTimestamp = $statusMessageTimestamp; + $this->statusIcon = $icon; + } + /** * @return IAction[] */ - public function getActions() { + public function getActions(): array { return $this->actions; } /** * sort the actions by priority and name */ - private function sortActions() { + private function sortActions(): void { usort($this->actions, function (IAction $action1, IAction $action2) { $prio1 = $action1->getPriority(); $prio2 = $action2->getPriority(); @@ -128,18 +127,18 @@ class Entry implements IEntry { }); } - /** - * @param array $contact key-value array containing additional properties - */ - public function setProperties(array $contact) { - $this->properties = $contact; + public function setProperty(string $propertyName, mixed $value) { + $this->properties[$propertyName] = $value; } /** - * @param string $key - * @return mixed + * @param array $properties key-value array containing additional properties */ - public function getProperty($key) { + public function setProperties(array $properties): void { + $this->properties = array_merge($this->properties, $properties); + } + + public function getProperty(string $key): mixed { if (!isset($this->properties[$key])) { return null; } @@ -147,9 +146,9 @@ class Entry implements IEntry { } /** - * @return array + * @return array{id: int|string|null, fullName: string, avatar: string|null, topAction: mixed, actions: array, lastMessage: '', emailAddresses: string[], profileTitle: string|null, profileUrl: string|null, status: string|null, statusMessage: null|string, statusMessageTimestamp: null|int, statusIcon: null|string, isUser: bool, uid: mixed} */ - public function jsonSerialize() { + public function jsonSerialize(): array { $topAction = !empty($this->actions) ? $this->actions[0]->jsonSerialize() : null; $otherActions = array_map(function (IAction $action) { return $action->jsonSerialize(); @@ -162,6 +161,23 @@ class Entry implements IEntry { 'topAction' => $topAction, 'actions' => $otherActions, 'lastMessage' => '', + 'emailAddresses' => $this->getEMailAddresses(), + 'profileTitle' => $this->profileTitle, + 'profileUrl' => $this->profileUrl, + 'status' => $this->status, + 'statusMessage' => $this->statusMessage, + 'statusMessageTimestamp' => $this->statusMessageTimestamp, + 'statusIcon' => $this->statusIcon, + 'isUser' => $this->getProperty('isUser') === true, + 'uid' => $this->getProperty('UID'), ]; } + + public function getStatusMessage(): ?string { + return $this->statusMessage; + } + + public function getStatusMessageTimestamp(): ?int { + return $this->statusMessageTimestamp; + } } diff --git a/lib/private/Contacts/ContactsMenu/Manager.php b/lib/private/Contacts/ContactsMenu/Manager.php index d08db5775e5..f8def45421b 100644 --- a/lib/private/Contacts/ContactsMenu/Manager.php +++ b/lib/private/Contacts/ContactsMenu/Manager.php @@ -1,73 +1,37 @@ <?php + /** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Julius Härtl <jus@bitgrid.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/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Contacts\ContactsMenu; +use Exception; use OCP\App\IAppManager; use OCP\Constants; +use OCP\Contacts\ContactsMenu\IBulkProvider; use OCP\Contacts\ContactsMenu\IEntry; +use OCP\Contacts\ContactsMenu\IProvider; use OCP\IConfig; use OCP\IUser; class Manager { - - /** @var ContactsStore */ - private $store; - - /** @var ActionProviderStore */ - private $actionProviderStore; - - /** @var IAppManager */ - private $appManager; - - /** @var IConfig */ - private $config; - - /** - * @param ContactsStore $store - * @param ActionProviderStore $actionProviderStore - * @param IAppManager $appManager - */ - public function __construct(ContactsStore $store, ActionProviderStore $actionProviderStore, IAppManager $appManager, IConfig $config) { - $this->store = $store; - $this->actionProviderStore = $actionProviderStore; - $this->appManager = $appManager; - $this->config = $config; + public function __construct( + private ContactsStore $store, + private ActionProviderStore $actionProviderStore, + private IAppManager $appManager, + private IConfig $config, + ) { } /** - * @param IUser $user - * @param string $filter - * @return array + * @throws Exception */ - public function getEntries(IUser $user, $filter) { + public function getEntries(IUser $user, ?string $filter): array { $maxAutocompleteResults = max(0, $this->config->getSystemValueInt('sharing.maxAutocompleteResults', Constants::SHARING_MAX_AUTOCOMPLETE_RESULTS_DEFAULT)); - $minSearchStringLength = $this->config->getSystemValueInt('sharing.minSearchStringLength', 0); + $minSearchStringLength = $this->config->getSystemValueInt('sharing.minSearchStringLength'); $topEntries = []; - if (strlen($filter) >= $minSearchStringLength) { + if (strlen($filter ?? '') >= $minSearchStringLength) { $entries = $this->store->getContacts($user, $filter, $maxAutocompleteResults); $sortedEntries = $this->sortEntries($entries); @@ -83,12 +47,9 @@ class Manager { } /** - * @param IUser $user - * @param integer $shareType - * @param string $shareWith - * @return IEntry + * @throws Exception */ - public function findOne(IUser $user, $shareType, $shareWith) { + public function findOne(IUser $user, int $shareType, string $shareWith): ?IEntry { $entry = $this->store->findOne($user, $shareType, $shareWith); if ($entry) { $this->processEntries([$entry], $user); @@ -101,22 +62,38 @@ class Manager { * @param IEntry[] $entries * @return IEntry[] */ - private function sortEntries(array $entries) { - usort($entries, function (IEntry $entryA, IEntry $entryB) { - return strcasecmp($entryA->getFullName(), $entryB->getFullName()); + private function sortEntries(array $entries): array { + usort($entries, function (Entry $entryA, Entry $entryB) { + $aStatusTimestamp = $entryA->getProperty(Entry::PROPERTY_STATUS_MESSAGE_TIMESTAMP); + $bStatusTimestamp = $entryB->getProperty(Entry::PROPERTY_STATUS_MESSAGE_TIMESTAMP); + if (!$aStatusTimestamp && !$bStatusTimestamp) { + return strcasecmp($entryA->getFullName(), $entryB->getFullName()); + } + if ($aStatusTimestamp === null) { + return 1; + } + if ($bStatusTimestamp === null) { + return -1; + } + return $bStatusTimestamp - $aStatusTimestamp; }); return $entries; } /** * @param IEntry[] $entries - * @param IUser $user + * @throws Exception */ - private function processEntries(array $entries, IUser $user) { + private function processEntries(array $entries, IUser $user): void { $providers = $this->actionProviderStore->getProviders($user); - foreach ($entries as $entry) { - foreach ($providers as $provider) { - $provider->process($entry); + + foreach ($providers as $provider) { + if ($provider instanceof IBulkProvider && !($provider instanceof IProvider)) { + $provider->process($entries); + } elseif ($provider instanceof IProvider && !($provider instanceof IBulkProvider)) { + foreach ($entries as $entry) { + $provider->process($entry); + } } } } diff --git a/lib/private/Contacts/ContactsMenu/Providers/EMailProvider.php b/lib/private/Contacts/ContactsMenu/Providers/EMailProvider.php index bb5e64d15aa..266125f5ed5 100644 --- a/lib/private/Contacts/ContactsMenu/Providers/EMailProvider.php +++ b/lib/private/Contacts/ContactsMenu/Providers/EMailProvider.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Contacts\ContactsMenu\Providers; use OCP\Contacts\ContactsMenu\IActionFactory; @@ -29,33 +12,20 @@ use OCP\Contacts\ContactsMenu\IProvider; use OCP\IURLGenerator; class EMailProvider implements IProvider { - - /** @var IActionFactory */ - private $actionFactory; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** - * @param IActionFactory $actionFactory - * @param IURLGenerator $urlGenerator - */ - public function __construct(IActionFactory $actionFactory, IURLGenerator $urlGenerator) { - $this->actionFactory = $actionFactory; - $this->urlGenerator = $urlGenerator; + public function __construct( + private IActionFactory $actionFactory, + private IURLGenerator $urlGenerator, + ) { } - /** - * @param IEntry $entry - */ - public function process(IEntry $entry) { + public function process(IEntry $entry): void { $iconUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/mail.svg')); foreach ($entry->getEMailAddresses() as $address) { if (empty($address)) { // Skip continue; } - $action = $this->actionFactory->newEMailAction($iconUrl, $address, $address); + $action = $this->actionFactory->newEMailAction($iconUrl, $address, $address, 'email'); $entry->addAction($action); } } diff --git a/lib/private/Contacts/ContactsMenu/Providers/LocalTimeProvider.php b/lib/private/Contacts/ContactsMenu/Providers/LocalTimeProvider.php new file mode 100644 index 00000000000..f62e989fd64 --- /dev/null +++ b/lib/private/Contacts/ContactsMenu/Providers/LocalTimeProvider.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Contacts\ContactsMenu\Providers; + +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Contacts\ContactsMenu\IActionFactory; +use OCP\Contacts\ContactsMenu\IEntry; +use OCP\Contacts\ContactsMenu\IProvider; +use OCP\IConfig; +use OCP\IDateTimeFormatter; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\L10N\IFactory as IL10NFactory; + +class LocalTimeProvider implements IProvider { + public function __construct( + private IActionFactory $actionFactory, + private IL10NFactory $l10nFactory, + private IURLGenerator $urlGenerator, + private IUserManager $userManager, + private ITimeFactory $timeFactory, + private IDateTimeFormatter $dateTimeFormatter, + private IConfig $config, + private IUserSession $currentSession, + ) { + } + + public function process(IEntry $entry): void { + $targetUserId = $entry->getProperty('UID'); + $targetUser = $this->userManager->get($targetUserId); + if (!empty($targetUser)) { + $timezoneStringTarget = $this->config->getUserValue($targetUser->getUID(), 'core', 'timezone') ?: $this->config->getSystemValueString('default_timezone', 'UTC'); + $timezoneTarget = new \DateTimeZone($timezoneStringTarget); + $localTimeTarget = $this->timeFactory->getDateTime('now', $timezoneTarget); + $localTimeString = $this->dateTimeFormatter->formatTime($localTimeTarget, 'short', $timezoneTarget); + + $l = $this->l10nFactory->get('lib'); + $currentUser = $this->currentSession->getUser(); + if ($currentUser !== null) { + $timezoneStringCurrent = $this->config->getUserValue($currentUser->getUID(), 'core', 'timezone') ?: $this->config->getSystemValueString('default_timezone', 'UTC'); + $timezoneCurrent = new \DateTimeZone($timezoneStringCurrent); + $localTimeCurrent = $this->timeFactory->getDateTime('now', $timezoneCurrent); + + // Get the timezone offsets to GMT on this very time (needed to handle daylight saving time) + $timeOffsetCurrent = $timezoneCurrent->getOffset($localTimeCurrent); + $timeOffsetTarget = $timezoneTarget->getOffset($localTimeTarget); + // Get the difference between the current users offset to GMT and then targets user to GMT + $timeOffset = $timeOffsetTarget - $timeOffsetCurrent; + if ($timeOffset === 0) { + // No offset means both users are in the same timezone + $timeOffsetString = $l->t('same time'); + } else { + // We need to cheat here as the offset could be up to 26h we can not use formatTime. + $hours = abs((int)($timeOffset / 3600)); + $minutes = abs(($timeOffset / 60) % 60); + // TRANSLATORS %n hours in a short form + $hoursString = $l->n('%nh', '%nh', $hours); + // TRANSLATORS %n minutes in a short form + $minutesString = $l->n('%nm', '%nm', $minutes); + + $timeOffsetString = ($hours > 0 ? $hoursString : '') . ($minutes > 0 ? $minutesString : ''); + + if ($timeOffset > 0) { + // TRANSLATORS meaning the user is %s time ahead - like 1h30m + $timeOffsetString = $l->t('%s ahead', [$timeOffsetString]); + } else { + // TRANSLATORS meaning the user is %s time behind - like 1h30m + $timeOffsetString = $l->t('%s behind', [$timeOffsetString]); + } + } + $profileActionText = "{$localTimeString} • {$timeOffsetString}"; + } else { + $profileActionText = $l->t('Local time: %s', [$localTimeString]); + } + + $iconUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/recent.svg')); + $action = $this->actionFactory->newLinkAction($iconUrl, $profileActionText, '#', 'timezone'); + // Order after the profile page + $action->setPriority(19); + $entry->addAction($action); + } + } +} diff --git a/lib/private/Contacts/ContactsMenu/Providers/ProfileProvider.php b/lib/private/Contacts/ContactsMenu/Providers/ProfileProvider.php new file mode 100644 index 00000000000..d00573aaa96 --- /dev/null +++ b/lib/private/Contacts/ContactsMenu/Providers/ProfileProvider.php @@ -0,0 +1,43 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Contacts\ContactsMenu\Providers; + +use OC\Profile\ProfileManager; +use OCP\Contacts\ContactsMenu\IActionFactory; +use OCP\Contacts\ContactsMenu\IEntry; +use OCP\Contacts\ContactsMenu\IProvider; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory as IL10NFactory; + +class ProfileProvider implements IProvider { + public function __construct( + private IActionFactory $actionFactory, + private ProfileManager $profileManager, + private IL10NFactory $l10nFactory, + private IURLGenerator $urlGenerator, + private IUserManager $userManager, + ) { + } + + public function process(IEntry $entry): void { + $targetUserId = $entry->getProperty('UID'); + $targetUser = $this->userManager->get($targetUserId); + if (!empty($targetUser)) { + if ($this->profileManager->isProfileEnabled($targetUser)) { + $iconUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/profile.svg')); + $profileActionText = $this->l10nFactory->get('lib')->t('View profile'); + $profileUrl = $this->urlGenerator->linkToRouteAbsolute('profile.ProfilePage.index', ['targetUserId' => $targetUserId]); + $action = $this->actionFactory->newLinkAction($iconUrl, $profileActionText, $profileUrl, 'profile'); + // Set highest priority (by descending order), other actions have the default priority 10 as defined in lib/private/Contacts/ContactsMenu/Actions/LinkAction.php + $action->setPriority(20); + $entry->addAction($action); + } + } + } +} |