Skip to content
Merged
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
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,9 @@ Settings → Appearance offers:

These appearance choices are saved in the browser, not on the account, so each browser keeps its own. Settings → Profile lets you choose one of six lane colors (Orange, Green, Violet, Teal, Rose, Ochre), a custom color, or the automatic color used around your identity and activity graph.

Profile settings also let you share a timezone. Timestamps can show the author's local time as well as the viewer's when a timezone is shared. Leaving it unset uses the browser's timezone for your display without publishing your timezone. These /dev/null preferences are separate from your GitHub-managed profile information.
Profile settings also let you share a timezone. Timestamps can show the author's local time as well as the viewer's when a timezone is shared. Leaving it unset uses the browser's timezone for your display without publishing your timezone. A separate, off-by-default setting shows your current local time on your profile, next to your location and website; it only applies while a timezone is set, and clearing the timezone turns it off. These /dev/null preferences are separate from your GitHub-managed profile information.

Sources: [appearance settings](resources/js/pages/appearance/update.tsx), [lane picker](resources/js/components/lane-picker.tsx), [timezone picker](resources/js/components/timezone-picker.tsx), [timestamp tooltip](resources/js/components/timestamp-tooltip.tsx).
Sources: [appearance settings](resources/js/pages/appearance/update.tsx), [lane picker](resources/js/components/lane-picker.tsx), [timezone picker](resources/js/components/timezone-picker.tsx), [local time](resources/js/components/local-time.tsx), [timestamp tooltip](resources/js/components/timestamp-tooltip.tsx).

### Installing /dev/null as an app

Expand Down
1 change: 1 addition & 0 deletions app/Actions/CreateExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public function handle(User $user): array
'has_sponsors_listing' => $user->has_sponsors_listing,
'preferred_lane' => $user->preferred_lane,
'timezone' => $user->timezone,
'show_local_time' => $user->show_local_time,
'translation_language' => $user->translation_language,
'lane_color' => $user->lane_color,
'pasteis_mode' => $user->pasteis_mode,
Expand Down
7 changes: 5 additions & 2 deletions app/Actions/UpdateUserTimezone.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@

final readonly class UpdateUserTimezone
{
public function handle(User $user, ?string $timezone): void
public function handle(User $user, ?string $timezone, bool $showLocalTime = false): void
{
$user->update(['timezone' => $timezone]);
$user->update([
'timezone' => $timezone,
'show_local_time' => $timezone !== null && $showLocalTime,
]);
}
}
2 changes: 1 addition & 1 deletion app/Http/Controllers/UserTimezoneController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
{
public function update(UpdateUserTimezoneRequest $request, #[CurrentUser] User $user, UpdateUserTimezone $action): RedirectResponse
{
$action->handle($user, $request->timezone());
$action->handle($user, $request->timezone(), $request->showLocalTime());

Inertia::flash('toast', [
'type' => 'success',
Expand Down
6 changes: 6 additions & 0 deletions app/Http/Requests/UpdateUserTimezoneRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,17 @@ public function rules(): array
{
return [
'timezone' => ['present', 'nullable', 'string', 'timezone:all'],
'show_local_time' => ['nullable', 'boolean'],
];
}

public function timezone(): ?string
{
return $this->filled('timezone') ? $this->string('timezone')->value() : null;
}

public function showLocalTime(): bool
{
return $this->boolean('show_local_time');
}
}
3 changes: 3 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
* @property-read string|null $pinned_commit_id
* @property-read string|null $translation_language
* @property-read string|null $timezone
* @property-read bool $show_local_time
* @property-read LivePlatform|null $live_platform
* @property-read string|null $live_url
* @property-read CarbonInterface|null $live_ends_at
Expand Down Expand Up @@ -122,6 +123,7 @@ final class User extends Authenticatable
protected $attributes = [
'log' => Log::Following->value,
'translation_language' => null,
'show_local_time' => false,
'pasteis_mode' => false,
'remotes_enabled' => true,
'remotes_from' => RemotesFrom::Following->value,
Expand Down Expand Up @@ -171,6 +173,7 @@ public function casts(): array
'pasteis_given_on' => 'date',
'pinned_commit_id' => 'string',
'timezone' => 'string',
'show_local_time' => 'boolean',
'translation_language' => 'encrypted',
'log' => Log::class,
'onboarded_at' => 'datetime',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->boolean('show_local_time')->default(false);
});
}
};
44 changes: 44 additions & 0 deletions resources/js/components/local-time.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Clock } from 'lucide-react';
import { useSyncExternalStore } from 'react';
import { formatUtcOffset, formatZonedTime } from '@/lib/utils';

const MINUTE = 60_000;

function subscribe(onChange: () => void): () => void {
const interval = setInterval(onChange, MINUTE);

return () => clearInterval(interval);
}

/**
* Snapshots are whole minutes, so the clock re-renders when the minute turns rather than on every read.
*/
function currentMinute(): number {
return Math.floor(Date.now() / MINUTE) * MINUTE;
}

/**
* What time it is for someone who chose to share their timezone. It renders nothing on the server, where the
* current time is not the reader's.
*/
export function LocalTime({ timeZone }: { timeZone: string }) {
const minute = useSyncExternalStore(subscribe, currentMinute, () => null);

if (minute === null) {
return null;
}

const now = new Date(minute);

return (
<li className="flex items-center gap-1.5" data-test="local-time">
<Clock className="size-4" />
<span>
<time dateTime={now.toISOString()}>
{formatZonedTime(now.toISOString(), timeZone)}
</time>{' '}
({formatUtcOffset(now, timeZone)})
</span>
</li>
);
}
48 changes: 45 additions & 3 deletions resources/js/components/timezone-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import UserTimezoneController from '@/actions/App/Http/Controllers/UserTimezoneC
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { useUser } from '@/hooks/use-user';
import { browserTimeZone } from '@/lib/utils';

Expand Down Expand Up @@ -49,6 +51,7 @@ function matches(timeZone: TimeZone, query: string): boolean {
export default function TimezonePicker() {
const { user } = useUser();
const [selected, setSelected] = useState<string | null>(user.timezone);
const [showLocalTime, setShowLocalTime] = useState(user.show_local_time);
const [query, setQuery] = useState('');
const detected = useSyncExternalStore(
subscribe,
Expand All @@ -68,7 +71,7 @@ export default function TimezonePicker() {
<section className="px-4 py-6 sm:px-6">
<Heading
title="Timezone"
description="Hover a commit or reply time to see it in your timezone, and in the author's when they have set one. Others see yours the same way. Leave it unset to use your browser's timezone without sharing it."
description="Hover a commit or reply time to see it in your timezone, and in the author's when they have set one. Others see yours the same way. Leave it unset to use your browser's timezone without sharing it. Your local time only shows on your profile if you turn that on below."
/>

<Form
Expand All @@ -83,6 +86,14 @@ export default function TimezonePicker() {
value={selected ?? ''}
/>

<input
type="hidden"
name="show_local_time"
value={
selected !== null && showLocalTime ? '1' : '0'
}
/>

<Combobox
value={selected}
onChange={(value: string | null) =>
Expand Down Expand Up @@ -169,18 +180,49 @@ export default function TimezonePicker() {
{selected !== null && (
<button
type="button"
onClick={() => setSelected(null)}
onClick={() => {
setSelected(null);
setShowLocalTime(false);
}}
className="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
Unset
</button>
)}
</div>

<div className="mt-4 flex items-start gap-2">
<Checkbox
id="show-local-time"
checked={selected !== null && showLocalTime}
disabled={selected === null}
onCheckedChange={(checked) =>
setShowLocalTime(checked === true)
}
className="mt-0.5"
data-test="show-local-time-checkbox"
/>
<Label
htmlFor="show-local-time"
className="block text-sm leading-snug font-normal"
>
Show my local time on my profile
<span className="block text-muted-foreground">
Anyone who opens your profile sees the
current time in your timezone.
</span>
</Label>
</div>

<Button
type="submit"
className="mt-5"
disabled={processing || selected === user.timezone}
disabled={
processing ||
(selected === user.timezone &&
(selected === null ||
showLocalTime === user.show_local_time))
}
data-test="save-timezone-button"
>
Save timezone
Expand Down
18 changes: 18 additions & 0 deletions resources/js/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,24 @@ export function formatZonedTime(date: string, timeZone: string): string {
});
}

/**
* The offset a timezone has at a moment, like `UTC +02:00`, or `UTC` when it has none.
*/
export function formatUtcOffset(date: Date, timeZone: string): string {
const offset = new Intl.DateTimeFormat('en-US', {
timeZone,
timeZoneName: 'longOffset',
})
.formatToParts(date)
.find((part) => part.type === 'timeZoneName')?.value;

if (offset === undefined || offset === 'GMT' || offset === 'GMT+00:00') {
return 'UTC';
}

return offset.replace('GMT', 'UTC ');
}

export function formatZonedDate(date: string, timeZone: string): string {
const value = new Date(date);

Expand Down
4 changes: 4 additions & 0 deletions resources/js/pages/user/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
LiveAvatarMarker,
LiveStreamTrigger,
} from '@/components/live-stream-card';
import { LocalTime } from '@/components/local-time';
import { PageTabs } from '@/components/page-tabs';
import { PinCommitButton } from '@/components/pin-commit-button';
import { ProfileActionsMenu } from '@/components/profile-actions-menu';
Expand Down Expand Up @@ -266,6 +267,9 @@ export default function Show({
{user.location}
</li>
)}
{user.show_local_time && user.timezone && (
<LocalTime timeZone={user.timezone} />
)}
{user.website && (
<li className="flex min-w-0 items-center gap-1.5">
<Link2 className="size-4 shrink-0" />
Expand Down
1 change: 1 addition & 0 deletions resources/js/types/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type User = {
has_github_account: boolean;
lane: Lane;
timezone: string | null;
show_local_time: boolean;
/**
* Only set while the user is streaming.
*/
Expand Down
2 changes: 2 additions & 0 deletions tests/Feature/Controllers/ExportControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
'has_sponsors_listing' => true,
'preferred_lane' => 3,
'timezone' => 'Europe/Budapest',
'show_local_time' => true,
'translation_language' => 'ja',
'lane_color' => '#1a2b3c',
'pasteis_mode' => true,
Expand Down Expand Up @@ -93,6 +94,7 @@
'has_sponsors_listing' => true,
'preferred_lane' => 3,
'timezone' => 'Europe/Budapest',
'show_local_time' => true,
'translation_language' => 'ja',
'lane_color' => '#1a2b3c',
'pasteis_mode' => true,
Expand Down
15 changes: 15 additions & 0 deletions tests/Feature/Controllers/UserControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@
->where('commits.data.1.body', 'Older'));
});

it('only lets guests see the local time of users who opted in', function (): void {
User::factory()->create(['username' => 'shared', 'timezone' => 'Europe/Berlin', 'show_local_time' => true]);
User::factory()->create(['username' => 'private', 'timezone' => 'Europe/Berlin']);

$this->get('/@shared')
->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page
->where('user.timezone', 'Europe/Berlin')
->where('user.show_local_time', true));

$this->get('/@private')
->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page
->where('user.timezone', 'Europe/Berlin')
->where('user.show_local_time', false));
});

it('renders whether the user can be sponsored on github', function (): void {
$user = User::factory()->create(['has_sponsors_listing' => true]);

Expand Down
46 changes: 46 additions & 0 deletions tests/Feature/Controllers/UserTimezoneControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,52 @@
expect($user->refresh()->timezone)->toBe('Europe/Budapest');
});

it('may opt in to showing the local time', function (): void {
$user = User::factory()->create();

$this->actingAs($user)
->fromRoute('user-profile.edit')
->put(route('user-timezone.update'), ['timezone' => 'Europe/Budapest', 'show_local_time' => true])
->assertRedirectToRoute('user-profile.edit');

expect($user->refresh())
->timezone->toBe('Europe/Budapest')
->show_local_time->toBeTrue();
});

it('may opt out of showing the local time', function (): void {
$user = User::factory()->create(['timezone' => 'Europe/Budapest', 'show_local_time' => true]);

$this->actingAs($user)
->fromRoute('user-profile.edit')
->put(route('user-timezone.update'), ['timezone' => 'Europe/Budapest', 'show_local_time' => false]);

expect($user->refresh()->show_local_time)->toBeFalse();
});

it('never shows the local time without a timezone', function (): void {
$user = User::factory()->create(['timezone' => 'Europe/Budapest', 'show_local_time' => true]);

$this->actingAs($user)
->fromRoute('user-profile.edit')
->put(route('user-timezone.update'), ['timezone' => null, 'show_local_time' => true]);

expect($user->refresh())
->timezone->toBeNull()
->show_local_time->toBeFalse();
});

it('rejects a show_local_time that is not a boolean', function (): void {
$user = User::factory()->create();

$this->actingAs($user)
->fromRoute('user-profile.edit')
->put(route('user-timezone.update'), ['timezone' => 'Europe/Budapest', 'show_local_time' => 'maybe'])
->assertSessionHasErrors('show_local_time');

expect($user->refresh()->timezone)->toBeNull();
});

it('may clear the timezone', function (mixed $timezone): void {
$user = User::factory()->create(['timezone' => 'Europe/Budapest']);

Expand Down
Loading
Loading