Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(contacts): Show time difference for users in different timezones #50214

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions lib/private/Contacts/ContactsMenu/Providers/LocalTimeProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use OCP\IDateTimeFormatter;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory as IL10NFactory;

class LocalTimeProvider implements IProvider {
Expand All @@ -28,21 +29,59 @@ public function __construct(
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)) {
$timezone = $this->config->getUserValue($targetUser->getUID(), 'core', 'timezone') ?: $this->config->getSystemValueString('default_timezone', 'UTC');
$dateTimeZone = new \DateTimeZone($timezone);
$localTime = $this->dateTimeFormatter->formatTime($this->timeFactory->getDateTime(), 'short', $dateTimeZone);
$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);

$iconUrl = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'actions/recent.svg'));
$l = $this->l10nFactory->get('lib');
$profileActionText = $l->t('Local time: %s', [$localTime]);
$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}";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be translated, so that RTL languages also have first the time and then the "offset"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally this should also work, we do the same for app headlines and page titles on the frontend.
But if preffered I can add this as translated

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe @DorraJaouad can give it a quick test with arabic to see how it feels?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

Yeah, it looks good, offset comes in second position

} 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);
Expand Down
106 changes: 79 additions & 27 deletions tests/lib/Contacts/ContactsMenu/Providers/LocalTimeProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,22 @@
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory as IL10NFactory;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class LocalTimeProviderTest extends TestCase {
/** @var IActionFactory|MockObject */
private $actionFactory;
/** @var IL10N|MockObject */
private $l;
/** @var IL10NFactory|MockObject */
private $l10nFactory;
/** @var IURLGenerator|MockObject */
private $urlGenerator;
/** @var IUserManager|MockObject */
private $userManager;
/** @var ITimeFactory|MockObject */
private $timeFactory;
/** @var IDateTimeFormatter|MockObject */
private $dateTimeFormatter;
/** @var IConfig|MockObject */
private $config;

private IActionFactory&MockObject $actionFactory;
private IL10N&MockObject $l;
private IL10NFactory&MockObject $l10nFactory;
private IURLGenerator&MockObject $urlGenerator;
private IUserManager&MockObject $userManager;
private ITimeFactory&MockObject $timeFactory;
private IUserSession&MockObject $userSession;
private IDateTimeFormatter&MockObject $dateTimeFormatter;
private IConfig&MockObject $config;

private LocalTimeProvider $provider;

Expand All @@ -55,11 +50,18 @@ protected function setUp(): void {
->will($this->returnCallback(function ($text, $parameters = []) {
return vsprintf($text, $parameters);
}));
$this->l->expects($this->any())
->method('n')
->will($this->returnCallback(function ($text, $textPlural, $n, $parameters = []) {
$formatted = str_replace('%n', (string)$n, $n === 1 ? $text : $textPlural);
return vsprintf($formatted, $parameters);
}));
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->dateTimeFormatter = $this->createMock(IDateTimeFormatter::class);
$this->config = $this->createMock(IConfig::class);
$this->userSession = $this->createMock(IUserSession::class);

$this->provider = new LocalTimeProvider(
$this->actionFactory,
Expand All @@ -68,11 +70,50 @@ protected function setUp(): void {
$this->userManager,
$this->timeFactory,
$this->dateTimeFormatter,
$this->config
$this->config,
$this->userSession,
);
}

public function testProcess(): void {
public static function dataTestProcess(): array {
return [
'no current user' => [
false,
null,
null,
'Local time: 10:24',
],
'both UTC' => [
true,
null,
null,
'10:24 • same time',
],
'both same time zone' => [
true,
'Europe/Berlin',
'Europe/Berlin',
'11:24 • same time',
],
'1h behind' => [
true,
'Europe/Berlin',
'Europe/London',
'10:24 • 1h behind',
],
'4:45h ahead' => [
true,
'Europe/Berlin',
'Asia/Kathmandu',
'16:09 • 4h45m ahead',
],
];
}

/**
* @dataProvider dataTestProcess
*/
public function testProcess(bool $hasCurrentUser, ?string $currentUserTZ, ?string $targetUserTZ, string $expected): void {
$entry = $this->createMock(IEntry::class);
$entry->expects($this->once())
->method('getProperty')
Expand All @@ -91,18 +132,29 @@ public function testProcess(): void {
->with('lib')
->willReturn($this->l);

$this->config->method('getUserValue')
->with('user1', 'core', 'timezone')
->willReturn('America/Los_Angeles');
$this->config->method('getSystemValueString')
->with('default_timezone', 'UTC')
->willReturn('UTC');
$this->config
->method('getUserValue')
->willReturnMap([
['user1', 'core', 'timezone', '', $targetUserTZ],
['currentUser', 'core', 'timezone', '', $currentUserTZ],
]);

if ($hasCurrentUser) {
$currentUser = $this->createMock(IUser::class);
$currentUser->method('getUID')
->willReturn('currentUser');
$this->userSession->method('getUser')
->willReturn($currentUser);
}

$now = new \DateTime('2023-01-04 10:24:43');
$this->timeFactory->method('getDateTime')
->willReturn($now);
->willReturnCallback(fn ($time, $tz) => (new \DateTime('2023-01-04 10:24:43', new \DateTimeZone('UTC')))->setTimezone($tz));

$now = new \DateTime('2023-01-04 10:24:43');
$this->dateTimeFormatter->method('formatTime')
->with($now, 'short', $this->anything())
->willReturn('01:24');
->willReturnCallback(fn (\DateTime $time) => $time->format('H:i'));

$this->urlGenerator->method('imagePath')
->willReturn('actions/recent.svg');
Expand All @@ -115,7 +167,7 @@ public function testProcess(): void {
->method('newLinkAction')
->with(
'https://localhost/actions/recent.svg',
'Local time: 01:24',
$expected,
'#',
'timezone'
)
Expand Down
Loading