From 2dd9d471412b323d8be87515bc56b1560481a60b Mon Sep 17 00:00:00 2001 From: Malik Alleyne-Jones Date: Tue, 12 May 2026 10:48:59 -0400 Subject: [PATCH 1/2] feat: support display timezone for comment timestamps (#17) Adds `commentions.timezone` config and a `Config::resolveTimezoneUsing(Closure)` helper for per-request resolution (e.g. the authenticated user's preferred timezone). The closure wins over the static config value; both default to null, leaving timestamps in the storage timezone. `Comment::getCreatedAt/getUpdatedAt` and the matching getters on `RenderableComment` now route through `Config::applyTimezone()`, which clones the date before mutating to avoid affecting the underlying Eloquent attributes. --- README.md | 20 ++++++++++ config/commentions.php | 13 +++++++ src/Comment.php | 4 +- src/Config.php | 34 ++++++++++++++++ src/RenderableComment.php | 4 +- tests/TimezoneTest.php | 82 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 tests/TimezoneTest.php diff --git a/README.md b/README.md index 354711c..0b6c490 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,26 @@ class User extends Authenticatable implements Commenter, HasName, HasAvatar } ``` +### Adjusting the display timezone + +Comment timestamps are stored in the app default timezone, but can be displayed in a different one. Set the `timezone` config key for a fixed override, or wire a closure for per-request resolution (e.g. the authenticated user's timezone): + +```php +// config/commentions.php +return [ + // ... + 'timezone' => 'America/Chicago', +]; +``` + +```php +use Kirschbaum\Commentions\Config; + +Config::resolveTimezoneUsing(fn () => auth()->user()?->timezone); +``` + +The closure takes precedence over the config value. When both are `null`, dates render in the storage timezone. + ### Customizing TipTap Editor Styles You can customize the TipTap editor CSS classes used using the `Config` class. diff --git a/config/commentions.php b/config/commentions.php index 4267451..bbccd4a 100644 --- a/config/commentions.php +++ b/config/commentions.php @@ -46,6 +46,19 @@ 'allowed' => ['👍', '❤️', '😂', '😮', '😢', '🤔'], ], + /* + |-------------------------------------------------------------------------- + | Display timezone + |-------------------------------------------------------------------------- + | + | Timezone applied when rendering comment created/updated timestamps. + | When null, dates are rendered in the storage timezone (typically the + | app default). Set to an IANA name like "America/Chicago", or wire a + | per-user value via Config::resolveTimezoneUsing(fn () => auth()->user()?->timezone). + | + */ + 'timezone' => null, + /* |-------------------------------------------------------------------------- | Subscriptions diff --git a/src/Comment.php b/src/Comment.php index 91eca58..33b20b3 100644 --- a/src/Comment.php +++ b/src/Comment.php @@ -152,12 +152,12 @@ public function getParsedBody(): string public function getCreatedAt(): DateTime|CarbonInterface { - return $this->created_at; + return Config::applyTimezone($this->created_at); } public function getUpdatedAt(): DateTime|CarbonInterface { - return $this->updated_at; + return Config::applyTimezone($this->updated_at); } public function reactions(): HasMany diff --git a/src/Config.php b/src/Config.php index 052a2e2..3704bc4 100644 --- a/src/Config.php +++ b/src/Config.php @@ -17,6 +17,8 @@ class Config protected static ?Closure $resolveTipTapCssClasses = null; + protected static ?Closure $resolveTimezone = null; + public static function resolveAuthenticatedUserUsing(Closure $callback): void { static::$resolveAuthenticatedUser = $callback; @@ -101,6 +103,38 @@ public static function getComponentPrefix(): string return static::isLivewireV4() ? 'commentions.' : 'commentions::'; } + public static function resolveTimezoneUsing(Closure $callback): void + { + static::$resolveTimezone = $callback; + } + + public static function getTimezone(): ?string + { + if (static::$resolveTimezone instanceof Closure) { + return call_user_func(static::$resolveTimezone); + } + + return config('commentions.timezone'); + } + + public static function applyTimezone(\DateTime|\Carbon\CarbonInterface $dt): \DateTime|\Carbon\CarbonInterface + { + $tz = static::getTimezone(); + + if ($tz === null) { + return $dt; + } + + if ($dt instanceof \Carbon\CarbonInterface) { + return $dt->copy()->setTimezone($tz); + } + + $cloned = clone $dt; + $cloned->setTimezone(new \DateTimeZone($tz)); + + return $cloned; + } + public static function isLivewireV4(): bool { return version_compare( diff --git a/src/RenderableComment.php b/src/RenderableComment.php index a2bd98d..c9794de 100644 --- a/src/RenderableComment.php +++ b/src/RenderableComment.php @@ -82,12 +82,12 @@ public function getParsedBody(): string public function getCreatedAt(): DateTime|CarbonInterface { - return $this->createdAt; + return Config::applyTimezone($this->createdAt); } public function getUpdatedAt(): DateTime|CarbonInterface { - return $this->updatedAt; + return Config::applyTimezone($this->updatedAt); } public function getLabel(): ?string diff --git a/tests/TimezoneTest.php b/tests/TimezoneTest.php new file mode 100644 index 0000000..f8e7475 --- /dev/null +++ b/tests/TimezoneTest.php @@ -0,0 +1,82 @@ +set('commentions.timezone', null); + Config::resolveTimezoneUsing(fn () => config('commentions.timezone')); +}); + +test('comment dates are returned unmodified when no timezone is configured', function () { + config()->set('commentions.timezone', null); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + $comment = CommentModel::factory()->author($user)->commentable($post)->create([ + 'created_at' => Carbon::parse('2026-01-15 12:00:00', 'UTC'), + 'updated_at' => Carbon::parse('2026-01-15 12:00:00', 'UTC'), + ]); + + expect($comment->getCreatedAt()->format('Y-m-d H:i:s')) + ->toBe('2026-01-15 12:00:00'); +}); + +test('comment dates are converted when a timezone is configured', function () { + config()->set('commentions.timezone', 'America/Chicago'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + $comment = CommentModel::factory()->author($user)->commentable($post)->create([ + 'created_at' => Carbon::parse('2026-01-15 12:00:00', 'UTC'), + 'updated_at' => Carbon::parse('2026-01-15 12:00:00', 'UTC'), + ]); + + expect($comment->getCreatedAt()->format('Y-m-d H:i')) + ->toBe('2026-01-15 06:00'); +}); + +test('RenderableComment dates respect the timezone config', function () { + config()->set('commentions.timezone', 'Asia/Tokyo'); + + $renderable = new RenderableComment( + id: 1, + authorName: 'System', + body: 'Test', + createdAt: Carbon::parse('2026-01-15 12:00:00', 'UTC'), + updatedAt: Carbon::parse('2026-01-15 12:00:00', 'UTC'), + ); + + expect($renderable->getCreatedAt()->format('Y-m-d H:i')) + ->toBe('2026-01-15 21:00'); +}); + +test('resolveTimezoneUsing closure takes precedence over config', function () { + config()->set('commentions.timezone', 'UTC'); + Config::resolveTimezoneUsing(fn () => 'Europe/London'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + $comment = CommentModel::factory()->author($user)->commentable($post)->create([ + 'created_at' => Carbon::parse('2026-06-15 12:00:00', 'UTC'), + 'updated_at' => Carbon::parse('2026-06-15 12:00:00', 'UTC'), + ]); + + // London is BST (UTC+1) in June + expect($comment->getCreatedAt()->format('Y-m-d H:i')) + ->toBe('2026-06-15 13:00'); +}); + +test('applyTimezone does not mutate the source instance', function () { + config()->set('commentions.timezone', 'America/New_York'); + + $source = Carbon::parse('2026-01-15 12:00:00', 'UTC'); + $converted = Config::applyTimezone($source); + + expect($source->format('Y-m-d H:i'))->toBe('2026-01-15 12:00') + ->and($converted->format('Y-m-d H:i'))->toBe('2026-01-15 07:00'); +}); From 4cb11ec0335699ce60ed91b6c29fc8c6d4368f76 Mon Sep 17 00:00:00 2001 From: Malik Alleyne-Jones Date: Mon, 18 May 2026 20:27:53 -0400 Subject: [PATCH 2/2] Improve timezone fallback logic with closure returning null The closure result now takes precedence, falling back to the config value when null is returned. Empty string timezones are treated as no timezone. Added proper type imports and updated tests to cover the fallback behavior. --- README.md | 2 +- src/Config.php | 15 +++++++++------ tests/TimezoneTest.php | 26 +++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0b6c490..b397ff1 100644 --- a/README.md +++ b/README.md @@ -414,7 +414,7 @@ use Kirschbaum\Commentions\Config; Config::resolveTimezoneUsing(fn () => auth()->user()?->timezone); ``` -The closure takes precedence over the config value. When both are `null`, dates render in the storage timezone. +The closure result takes precedence; when it returns `null`, the `timezone` config value is used as a fallback. When neither yields a value, dates render in the storage timezone. ### Customizing TipTap Editor Styles diff --git a/src/Config.php b/src/Config.php index 3704bc4..6de8201 100644 --- a/src/Config.php +++ b/src/Config.php @@ -2,8 +2,11 @@ namespace Kirschbaum\Commentions; +use Carbon\CarbonInterface; use Closure; use Composer\InstalledVersions; +use DateTime; +use DateTimeZone; use InvalidArgumentException; use Kirschbaum\Commentions\Contracts\Commenter; @@ -103,7 +106,7 @@ public static function getComponentPrefix(): string return static::isLivewireV4() ? 'commentions.' : 'commentions::'; } - public static function resolveTimezoneUsing(Closure $callback): void + public static function resolveTimezoneUsing(?Closure $callback = null): void { static::$resolveTimezone = $callback; } @@ -111,26 +114,26 @@ public static function resolveTimezoneUsing(Closure $callback): void public static function getTimezone(): ?string { if (static::$resolveTimezone instanceof Closure) { - return call_user_func(static::$resolveTimezone); + return call_user_func(static::$resolveTimezone) ?? config('commentions.timezone'); } return config('commentions.timezone'); } - public static function applyTimezone(\DateTime|\Carbon\CarbonInterface $dt): \DateTime|\Carbon\CarbonInterface + public static function applyTimezone(DateTime|CarbonInterface $dt): DateTime|CarbonInterface { $tz = static::getTimezone(); - if ($tz === null) { + if (blank($tz)) { return $dt; } - if ($dt instanceof \Carbon\CarbonInterface) { + if ($dt instanceof CarbonInterface) { return $dt->copy()->setTimezone($tz); } $cloned = clone $dt; - $cloned->setTimezone(new \DateTimeZone($tz)); + $cloned->setTimezone(new DateTimeZone($tz)); return $cloned; } diff --git a/tests/TimezoneTest.php b/tests/TimezoneTest.php index f8e7475..a9afcdd 100644 --- a/tests/TimezoneTest.php +++ b/tests/TimezoneTest.php @@ -9,7 +9,7 @@ afterEach(function () { config()->set('commentions.timezone', null); - Config::resolveTimezoneUsing(fn () => config('commentions.timezone')); + Config::resolveTimezoneUsing(null); }); test('comment dates are returned unmodified when no timezone is configured', function () { @@ -80,3 +80,27 @@ expect($source->format('Y-m-d H:i'))->toBe('2026-01-15 12:00') ->and($converted->format('Y-m-d H:i'))->toBe('2026-01-15 07:00'); }); + +test('an empty-string timezone is treated as no timezone', function () { + Config::resolveTimezoneUsing(fn () => ''); + + $source = Carbon::parse('2026-01-15 12:00:00', 'UTC'); + + expect(Config::applyTimezone($source)->format('Y-m-d H:i')) + ->toBe('2026-01-15 12:00'); +}); + +test('resolveTimezoneUsing falls back to the config value when the closure returns null', function () { + config()->set('commentions.timezone', 'America/Chicago'); + Config::resolveTimezoneUsing(fn () => null); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + $comment = CommentModel::factory()->author($user)->commentable($post)->create([ + 'created_at' => Carbon::parse('2026-01-15 12:00:00', 'UTC'), + 'updated_at' => Carbon::parse('2026-01-15 12:00:00', 'UTC'), + ]); + + expect($comment->getCreatedAt()->format('Y-m-d H:i')) + ->toBe('2026-01-15 06:00'); +});