diff --git a/README.md b/README.md index 354711c..b397ff1 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 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 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..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; @@ -17,6 +20,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 +106,38 @@ public static function getComponentPrefix(): string return static::isLivewireV4() ? 'commentions.' : 'commentions::'; } + public static function resolveTimezoneUsing(?Closure $callback = null): void + { + static::$resolveTimezone = $callback; + } + + public static function getTimezone(): ?string + { + if (static::$resolveTimezone instanceof Closure) { + return call_user_func(static::$resolveTimezone) ?? config('commentions.timezone'); + } + + return config('commentions.timezone'); + } + + public static function applyTimezone(DateTime|CarbonInterface $dt): DateTime|CarbonInterface + { + $tz = static::getTimezone(); + + if (blank($tz)) { + return $dt; + } + + if ($dt instanceof 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..a9afcdd --- /dev/null +++ b/tests/TimezoneTest.php @@ -0,0 +1,106 @@ +set('commentions.timezone', null); + Config::resolveTimezoneUsing(null); +}); + +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'); +}); + +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'); +});