feat(timeline): social feed with posts, replies, and moderation events#223
Conversation
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Repository YAML (base), Central YAML (inherited) Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (58)
📝 WalkthroughWalkthroughThis PR adds a polymorphic Timeline subsystem: migrations for activity_timeline and activity_post_entries; PostEntry and Timeline Eloquent models with media support and factories; DTOs and transactional actions for creating posts/replies, deleting replies, toggling pins, and publishing moderation events; a TimelineFeed query builder; listener and observer wiring for moderation events; Blade components and Livewire components for composer, feed, post display, replies, and thread page; Filament pages and a PanelAppServiceProvider; bootstrap provider registration and composer dependency bumps; and extensive unit and feature tests with documentation. Possibly related issues
Possibly related PRs
Suggested reviewers
|
danielhe4rt
left a comment
There was a problem hiding this comment.
Some nitpicks but looks good so far.
There was a problem hiding this comment.
Actionable comments posted: 12
♻️ Duplicate comments (1)
app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php (1)
11-14:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAdd defensive handling for short or empty user names.
This has the same issue as in
composer.blade.php: the code assumesauth()->user()->nameexists and has at least 2 characters.🛡️ Proposed fix to handle edge cases
{{ - str(auth()->user()->name) + str(auth()->user()->name ?? 'U') ->substr(0, 2) + ->padRight(2, '?') ->upper() }}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php` around lines 11 - 14, The current snippet assumes auth()->user()->name exists and has >=2 characters; update the template to defensively obtain the name (e.g., use optional(auth()->user())->name ?? '' or the optional helper) then trim it and take the first two characters only if present, falling back to a safe placeholder (like '?' or the first character) before calling ->upper(); locate the expression that uses auth()->user()->name in the reply-composer.blade.php and replace it with a guarded expression that handles null/empty names and short names, ensuring the final value passed to ->substr(0, 2)->upper() is always a non-null string.
🧹 Nitpick comments (9)
app-modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.php (1)
20-21: ⚡ Quick winConsider adding foreign key constraints for referential integrity.
The
root_idandparent_idcolumns are self-referential (pointing to other timeline entries) but lack explicit foreign key constraints. This could lead to orphaned replies if a parent or root post is deleted directly.🔗 Proposed fix to add self-referential constraints
- $table->foreignUuid('root_id')->nullable(); - $table->foreignUuid('parent_id')->nullable(); + $table->foreignUuid('root_id')->nullable()->constrained('activity_timeline')->nullOnDelete(); + $table->foreignUuid('parent_id')->nullable()->constrained('activity_timeline')->nullOnDelete();This ensures that when a parent/root is deleted, the references are automatically nullified, preventing orphaned records.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.php` around lines 20 - 21, The root_id and parent_id columns are defined as foreignUuid but lack referential constraints; update the migration where $table->foreignUuid('root_id')->nullable() and $table->foreignUuid('parent_id')->nullable() to add self-referential foreign key constraints pointing to the timeline table (the same table created in this migration) and use ON DELETE SET NULL (e.g., via constrained(...)->nullOnDelete() or foreign(...)->references('id')->on('activity_timelines')->nullOnDelete()) so deleting a parent/root nullifies these references and prevents orphaned records.app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php (1)
33-60: ⚡ Quick winAdd a tenant-isolation assertion for unpin behavior.
Current coverage verifies “previous pinned post” logic, but not that unpinning is constrained to the same tenant for the same user.
Suggested additional test
+test('pinning in one tenant does not unpin another tenant pinned post', function (): void { + $user = User::factory()->create(); + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $entryA = PostEntry::factory()->create(); + $entryB = PostEntry::factory()->create(); + + $pinnedInA = Timeline::factory()->for($user)->create([ + 'tenant_id' => $tenantA->id, + 'postable_type' => (new PostEntry)->getMorphClass(), + 'postable_id' => $entryA->id, + 'pinned' => true, + ]); + + $postInB = Timeline::factory()->for($user)->create([ + 'tenant_id' => $tenantB->id, + 'postable_type' => (new PostEntry)->getMorphClass(), + 'postable_id' => $entryB->id, + ]); + + resolve(TogglePinPost::class)->handle($user, $postInB); + + expect($pinnedInA->fresh()->pinned)->toBeTrue() + ->and($postInB->fresh()->pinned)->toBeTrue(); +});🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php` around lines 33 - 60, The test needs an assertion that unpinning only affects posts in the same tenant: create a second tenant and a pinned Timeline post for the same $user but with that other tenant (use Tenant::factory() to create it, e.g. $otherTenant) and a pinned $otherTenantPost, then call TogglePinPost::handle($user, $secondPost) and assert $otherTenantPost->fresh()->pinned is still true while $firstPost->fresh()->pinned becomes false and $secondPost->fresh()->pinned becomes true; reference the existing Timeline factory variables ($firstPost, $secondPost) and the TogglePinPost::handle call when adding the new setup and assertion.app-modules/panel-app/src/Livewire/Timeline/PostShow.php (1)
51-51: ⚡ Quick winConsider limiting children query for performance.
The children relationship is eager-loaded with
latest()ordering but no limit. According to the AI summary, the view displays "up-to-3 children as replies". Consider adding->limit(3)to prevent loading excessive data when a post has many replies.⚡ Proposed performance optimization
-'children' => fn ($q) => $q->with('user', 'postable')->latest(), +'children' => fn ($q) => $q->with('user', 'postable')->latest()->limit(3),🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/panel-app/src/Livewire/Timeline/PostShow.php` at line 51, The children eager-load closure ('children' => fn ($q) => $q->with('user', 'postable')->latest()) can load an unbounded number of replies; update the closure used in PostShow.php to constrain the result set (e.g., append ->limit(3) or ->take(3) after latest()) so only up to three child posts are loaded, preserving the latest ordering and associated relations for performance.app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php (2)
3-9: ⚡ Quick winConsider adding type safety for postable.
The component assumes
$timeline->postableis aModerationEventbut doesn't validate this. If called with a different postable type, it could cause errors. Consider adding a type check or using a more specific component property.🛡️ Proposed type safety improvement
`@php` $event = $timeline->postable; + if (!$event instanceof \He4rt\Activity\Moderation\Models\ModerationEvent) { + throw new \InvalidArgumentException('Expected ModerationEvent postable'); + } $moderatorVisible = $event->metadata['moderator_visible'] ?? false;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php` around lines 3 - 9, The template assumes $timeline->postable is a ModerationEvent (used as $event) but doesn't validate it; add a runtime type guard for $timeline->postable (e.g., check instanceof the ModerationEvent/ModerationEvent model or appropriate class) before reading metadata and computing $moderatorVisible, $reportsCount, $violationType and $isBan, and provide a safe fallback (skip rendering or render an error/empty state) when the check fails so the component never dereferences an unexpected postable type.
59-59: 💤 Low valueConsider internationalization for hardcoded strings.
The view contains hardcoded Portuguese strings ("foi banido permanentemente", "removido da comunidade", "Motivo:", etc.) that would prevent localization. Consider using Laravel's
__()translation helper for multi-language support.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php` at line 59, The Portuguese strings in the Blade component (e.g. the conditional text using $isBan: "foi banido permanentemente" vs "removido da comunidade", and labels like "Motivo:") must be replaced with localization helpers; update timeline/moderation-event.blade.php to use __('timeline.banned_permanently'), __('timeline.removed'), __('timeline.reason') (or similar keys) and pass any dynamic values via trans() or __() placeholders as needed, then add those keys and translations to your resources/lang/* files so the view uses the translation strings instead of hardcoded Portuguese.app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php (1)
63-67: ⚡ Quick winOptimize getMorphClass() call.
Creating a new
Userinstance solely to callgetMorphClass()is inefficient. Use the static::classconstant or a static method instead.⚡ Proposed optimization
return ExternalIdentity::query() ->where('model_id', $userId) - ->where('model_type', (new User)->getMorphClass()) + ->where('model_type', User::class) ->where('tenant_id', $tenantId) ->value('id');Or if you've configured a custom morph map:
+use Illuminate\Database\Eloquent\Relations\Relation; + - ->where('model_type', (new User)->getMorphClass()) + ->where('model_type', Relation::getMorphedModel('user') ?? User::class)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php` around lines 63 - 67, The code creates a new User instance just to call getMorphClass() which is unnecessary; update the ExternalIdentity query (the where('model_type', (new User)->getMorphClass()) call) to use the class constant instead (where('model_type', User::class)) or your app's configured morph map value so you avoid instantiating User solely for the morph type.app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php (1)
44-56: ⚡ Quick winVerify public disk usage is appropriate for tenant-isolated timeline uploads.
The file upload configuration stores timeline images on the
publicdisk attimeline-uploads. While the timeline feed itself is authenticated and tenant-scoped, the image files on the public disk lack filesystem-level tenant isolation. Confirm this approach is acceptable for your use case:
- Uploaded images will be accessible via direct URL without authentication
- In a multi-tenant system, if URL patterns are predictable, one tenant could potentially access another's images
- For stronger tenant isolation, consider using a private disk with signed URLs instead
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php` around lines 44 - 56, The FileUpload field configuration FileUpload::make('images') currently uses disk('public') and directory('timeline-uploads'), which exposes uploaded timeline images via public URLs and lacks filesystem-level tenant isolation; update this to use a tenant-aware storage approach—either switch to a non-public/private disk (e.g., disk('tenant_private')) and serve images via signed URLs, or include tenant scoping in the directory path (e.g., directory("tenants/{tenant_id}/timeline-uploads")) and ensure any retrieval endpoints validate tenant access; change the disk('public') call and/or directory('timeline-uploads') in ReplyComposer.php accordingly and wire up signed URL generation where files will be served.app-modules/panel-app/src/Livewire/Timeline/Feed.php (1)
21-24: 💤 Low valueEmpty
refresh()body is intentional but opaque — add a brief docblock.In Livewire 3 a listener method that does nothing still forces a re-render of the component, which is what drives the feed refresh on
timeline.*events. A one-line PHPDoc explaining the intent will spare future readers from “dead code” suspicion (and from accidentally deleting it).📝 Suggested docblock
#[On('timeline.post-created')] #[On('timeline.reply-created')] #[On('timeline.reply-deleted')] + /** + * No-op handler: receiving the event triggers a Livewire re-render, + * which re-runs render() and refreshes the feed query. + */ public function refresh(): void {}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/panel-app/src/Livewire/Timeline/Feed.php` around lines 21 - 24, Add a brief PHPDoc to the empty Livewire listener method refresh() that explains it intentionally has no body because Livewire 3 will re-render the component when the method is invoked; locate the method annotated with #[On('timeline.post-created')], #[On('timeline.reply-created')], #[On('timeline.reply-deleted')] in the Feed class and insert a one-line docblock above public function refresh(): void {} describing that the empty method triggers a component refresh and must be kept to avoid accidental removal.app-modules/panel-app/src/Livewire/Timeline/Composer.php (1)
57-72: ⚡ Quick winConsider surfacing success/failure feedback to the user.
After a successful post, the form is reset and an event is dispatched, but the composer gives no visible confirmation. A
Filament\Notifications\Notification::make()->success()->send()on success (and a try/catch with adangernotification on failure) would meaningfully improve UX and make moderation/post-creation errors observable instead of silently swallowed.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app-modules/panel-app/src/Livewire/Timeline/Composer.php` around lines 57 - 72, Wrap the post() handler's CreatePost invocation in a try/catch, using Filament\Notifications\Notification::make()->success()->send() after the successful resolve(CreatePost::class)->handle(new CreatePostDTO(...)) and using Notification::make()->danger()->send() in the catch with the exception message; keep the existing form reset ($this->form->fill()) and event dispatch ($this->dispatch('timeline.post-created')) in the success path so they still run only on success, and rethrow or handle errors as appropriate for your flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app-modules/activity/src/Timeline/Actions/CreatePost.php`:
- Around line 27-29: Replace the current pattern in CreatePost (and similarly in
CreateReply) where the code resolves a local filesystem path via
Storage::disk('public')->path($image) and calls
$postEntry->addMedia($path)->toMediaCollection('images'); with the disk-native
approach: remove the path conversion and call
$postEntry->addMediaFromDisk($image, 'public')->toMediaCollection('images') so
the code works with non-local disks (S3/R2); update the loop over $dto->images
accordingly and ensure you use addMediaFromDisk on the same model instance
($postEntry or reply model) and keep the collection name 'images'.
In `@app-modules/activity/src/Timeline/Actions/CreateReply.php`:
- Line 22: The parent timeline lookup in CreateReply.php uses
Timeline::query()->findOrFail($dto->parentTimelineId) without tenant scoping;
add tenant isolation by extending CreateReplyDTO to carry tenantId (e.g., public
int $tenantId) and change the lookup to query for the parent timeline with both
id and tenant_id (e.g., Timeline::query()->where('id',
$dto->parentTimelineId)->where('tenant_id', $dto->tenantId)->firstOrFail()), and
ensure any subsequent creation or association logic that uses the parent
timeline (the reply creation block around the code that uses $parentTimeline)
also uses $dto->tenantId so replies are created under the correct tenant
context.
In `@app-modules/activity/src/Timeline/Actions/DeleteReply.php`:
- Around line 13-23: In DeleteReply::handle, wrap the delete operations for
$reply and the optional $postable in a single database transaction so both
succeed or both roll back on error; use DB::transaction (or the database
facade/helper) to run the $reply->delete() and the conditional
$postable?->forceDelete() inside the closure, ensuring the exception from
forceDelete bubbles and triggers a rollback; keep the existing authorization
checks and make no other behavioral changes.
In `@app-modules/activity/src/Timeline/Actions/PublishModerationEntry.php`:
- Around line 24-33: Validate the moderator identity before creating the
Timeline row: check $event->moderator exists and that
$event->moderator->model_type (or equivalent) denotes a User and that
$event->moderator->model_id is non-null and that the moderator's tenant matches
$event->tenant_id; if any check fails return null instead of proceeding to
Timeline::query()->create; update the logic around $userId and the create call
in PublishModerationEntry.php to enforce these tenant/model constraints.
In `@app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php`:
- Around line 39-54: Wrap the ModerationEvent creation in a DB::transaction and
make it idempotent by using updateOrCreate keyed on action_id instead of create;
specifically, replace the ModerationEvent::query()->create(...) call in
PublishModerationToTimeline with DB::transaction(fn() =>
ModerationEvent::query()->updateOrCreate(['action_id' => $action->id], [
...attributes... ])) so the ModerationEvent and any observer-triggered Timeline
creation run in the same transaction, and add a DB unique index on action_id in
the migration to enforce uniqueness at the database level.
In
`@app-modules/panel-app/resources/views/components/timeline/engagement.blade.php`:
- Around line 35-37: The button that triggers the Livewire action togglePin is
missing an explicit type and may act as a form submitter; update the button
element (the one using wire:click="togglePin" and checking $timeline->pinned) to
include type="button" to prevent accidental form submission, leaving the
existing classes and wire:click handler unchanged.
In `@app-modules/panel-app/resources/views/components/timeline/header.blade.php`:
- Around line 23-26: The "Fixado" label is hidden on small screens which removes
accessible text for screen readers; update the pinned status block (the div
containing x-heroicon-s-map-pin and the span with class "hidden sm:inline") to
include an additional visually-hidden screen-reader-only element (e.g., a <span
class="sr-only">Fixado</span>) so the pinned state is announced on mobile while
preserving the existing visible span for larger screens.
In
`@app-modules/panel-app/resources/views/components/timeline/post-entry.blade.php`:
- Around line 11-14: The img tag using $media->getUrl() currently has an empty
alt attribute; update the img element to provide descriptive alt text (e.g. use
$media->alt, $media->getCustomProperty('alt'), $media->getDescription(), or fall
back to the post title or filename) so assistive tech can convey the image
meaning—modify the alt attribute on the <img src="{{ $media->getUrl() }}">
element to use the chosen descriptive value with a safe fallback.
In `@app-modules/panel-app/resources/views/livewire/timeline/composer.blade.php`:
- Around line 11-14: The avatar initials generation assumes auth()->user()->name
is non-empty and at least 2 chars; modify the blade expression that calls
str(auth()->user()->name)->substr(0, 2)->upper() to defensively handle
null/short names by first coalescing to an empty string or a fallback (e.g.,
auth()->user()->name ?? ''), trimming it, and then taking up to 2 characters (or
using conditional logic to use the first character or a placeholder) before
calling upper(); update the expression around str(...)->substr(0,2)->upper() to
use this safe fallback so it never errors on null or short names.
In
`@app-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.php`:
- Line 65: The image tag with alt="" in the thread-replies Blade template should
provide meaningful accessible text: update the img alt attribute to use the
media's metadata when available (e.g., use the reply/attachment alt/title field)
and fall back to a sensible default like "Image attached to reply"; if the image
is purely decorative, explicitly mark it as decorative by keeping alt="" and
adding aria-hidden="true" or role="presentation". Locate the img element in
thread-replies.blade.php and replace the static alt="" with a conditional that
uses the attachment's descriptive field (e.g., $attachment->alt or
$reply->media->alt) or the default string, or mark decorative images explicitly.
In `@app-modules/panel-app/src/Livewire/Timeline/Feed.php`:
- Around line 28-36: The query in Livewire\Timeline\Feed (using
TimelineFeed->builder()) is unnecessarily eager-loading the 'children' relation
(the entry 'children' => fn ($q) => $q->with('user', 'postable')->latest()) even
though child posts are loaded separately by the timeline-post-show component;
remove that 'children' clause from the with([...]) call so the builder only
eager-loads 'user' and 'postable' (leave the withCount('children','reactions')
intact) and let the child component handle its own limited reply queries.
In `@app-modules/panel-app/src/Pages/ThreadPage.php`:
- Around line 25-47: mount() currently runs an exists() query and getTimeline()
re-runs the same query; consolidate by loading the full Timeline once in mount()
(use Timeline::query()->where('id', $record)->where('tenant_id',
filament()->getTenant()->getKey())->whereNull('parent_id')->with(['user','postable','reactions'])->withCount('children','reactions')->firstOrFail())
and store it on the component (e.g. $this->timeline) so subsequent calls are
free; then change getTimeline() to simply return the memoized $this->timeline
(or lazy-load it if absent) to preserve behavior and avoid duplicate DB
round-trips.
---
Duplicate comments:
In
`@app-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.php`:
- Around line 11-14: The current snippet assumes auth()->user()->name exists and
has >=2 characters; update the template to defensively obtain the name (e.g.,
use optional(auth()->user())->name ?? '' or the optional helper) then trim it
and take the first two characters only if present, falling back to a safe
placeholder (like '?' or the first character) before calling ->upper(); locate
the expression that uses auth()->user()->name in the reply-composer.blade.php
and replace it with a guarded expression that handles null/empty names and short
names, ensuring the final value passed to ->substr(0, 2)->upper() is always a
non-null string.
---
Nitpick comments:
In
`@app-modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.php`:
- Around line 20-21: The root_id and parent_id columns are defined as
foreignUuid but lack referential constraints; update the migration where
$table->foreignUuid('root_id')->nullable() and
$table->foreignUuid('parent_id')->nullable() to add self-referential foreign key
constraints pointing to the timeline table (the same table created in this
migration) and use ON DELETE SET NULL (e.g., via
constrained(...)->nullOnDelete() or
foreign(...)->references('id')->on('activity_timelines')->nullOnDelete()) so
deleting a parent/root nullifies these references and prevents orphaned records.
In `@app-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.php`:
- Around line 63-67: The code creates a new User instance just to call
getMorphClass() which is unnecessary; update the ExternalIdentity query (the
where('model_type', (new User)->getMorphClass()) call) to use the class constant
instead (where('model_type', User::class)) or your app's configured morph map
value so you avoid instantiating User solely for the morph type.
In `@app-modules/activity/tests/Unit/Timeline/TogglePinPostTest.php`:
- Around line 33-60: The test needs an assertion that unpinning only affects
posts in the same tenant: create a second tenant and a pinned Timeline post for
the same $user but with that other tenant (use Tenant::factory() to create it,
e.g. $otherTenant) and a pinned $otherTenantPost, then call
TogglePinPost::handle($user, $secondPost) and assert
$otherTenantPost->fresh()->pinned is still true while
$firstPost->fresh()->pinned becomes false and $secondPost->fresh()->pinned
becomes true; reference the existing Timeline factory variables ($firstPost,
$secondPost) and the TogglePinPost::handle call when adding the new setup and
assertion.
In
`@app-modules/panel-app/resources/views/components/timeline/moderation-event.blade.php`:
- Around line 3-9: The template assumes $timeline->postable is a ModerationEvent
(used as $event) but doesn't validate it; add a runtime type guard for
$timeline->postable (e.g., check instanceof the ModerationEvent/ModerationEvent
model or appropriate class) before reading metadata and computing
$moderatorVisible, $reportsCount, $violationType and $isBan, and provide a safe
fallback (skip rendering or render an error/empty state) when the check fails so
the component never dereferences an unexpected postable type.
- Line 59: The Portuguese strings in the Blade component (e.g. the conditional
text using $isBan: "foi banido permanentemente" vs "removido da comunidade", and
labels like "Motivo:") must be replaced with localization helpers; update
timeline/moderation-event.blade.php to use __('timeline.banned_permanently'),
__('timeline.removed'), __('timeline.reason') (or similar keys) and pass any
dynamic values via trans() or __() placeholders as needed, then add those keys
and translations to your resources/lang/* files so the view uses the translation
strings instead of hardcoded Portuguese.
In `@app-modules/panel-app/src/Livewire/Timeline/Composer.php`:
- Around line 57-72: Wrap the post() handler's CreatePost invocation in a
try/catch, using Filament\Notifications\Notification::make()->success()->send()
after the successful resolve(CreatePost::class)->handle(new CreatePostDTO(...))
and using Notification::make()->danger()->send() in the catch with the exception
message; keep the existing form reset ($this->form->fill()) and event dispatch
($this->dispatch('timeline.post-created')) in the success path so they still run
only on success, and rethrow or handle errors as appropriate for your flow.
In `@app-modules/panel-app/src/Livewire/Timeline/Feed.php`:
- Around line 21-24: Add a brief PHPDoc to the empty Livewire listener method
refresh() that explains it intentionally has no body because Livewire 3 will
re-render the component when the method is invoked; locate the method annotated
with #[On('timeline.post-created')], #[On('timeline.reply-created')],
#[On('timeline.reply-deleted')] in the Feed class and insert a one-line docblock
above public function refresh(): void {} describing that the empty method
triggers a component refresh and must be kept to avoid accidental removal.
In `@app-modules/panel-app/src/Livewire/Timeline/PostShow.php`:
- Line 51: The children eager-load closure ('children' => fn ($q) =>
$q->with('user', 'postable')->latest()) can load an unbounded number of replies;
update the closure used in PostShow.php to constrain the result set (e.g.,
append ->limit(3) or ->take(3) after latest()) so only up to three child posts
are loaded, preserving the latest ordering and associated relations for
performance.
In `@app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php`:
- Around line 44-56: The FileUpload field configuration
FileUpload::make('images') currently uses disk('public') and
directory('timeline-uploads'), which exposes uploaded timeline images via public
URLs and lacks filesystem-level tenant isolation; update this to use a
tenant-aware storage approach—either switch to a non-public/private disk (e.g.,
disk('tenant_private')) and serve images via signed URLs, or include tenant
scoping in the directory path (e.g.,
directory("tenants/{tenant_id}/timeline-uploads")) and ensure any retrieval
endpoints validate tenant access; change the disk('public') call and/or
directory('timeline-uploads') in ReplyComposer.php accordingly and wire up
signed URL generation where files will be served.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Central YAML (inherited)
Review profile: CHILL
Plan: Pro
Run ID: 8e46c28b-2a97-498b-afdb-26d88a5a97b5
⛔ Files ignored due to path filters (1)
composer.lockis excluded by!**/*.lock
📒 Files selected for processing (58)
app-modules/activity/database/factories/PostEntryFactory.phpapp-modules/activity/database/factories/TimelineFactory.phpapp-modules/activity/database/migrations/2026_05_09_154113_create_activity_timeline_table.phpapp-modules/activity/database/migrations/2026_05_09_155946_create_activity_post_entries_table.phpapp-modules/activity/docs/timeline.mdapp-modules/activity/routes/message-routes.phpapp-modules/activity/src/ActivityServiceProvider.phpapp-modules/activity/src/Moderation/Models/ModerationEvent.phpapp-modules/activity/src/Timeline/Actions/CreatePost.phpapp-modules/activity/src/Timeline/Actions/CreateReply.phpapp-modules/activity/src/Timeline/Actions/DeleteReply.phpapp-modules/activity/src/Timeline/Actions/PublishModerationEntry.phpapp-modules/activity/src/Timeline/Actions/TogglePinPost.phpapp-modules/activity/src/Timeline/DTOs/CreatePostDTO.phpapp-modules/activity/src/Timeline/DTOs/CreateReplyDTO.phpapp-modules/activity/src/Timeline/Delegated/PostEntry.phpapp-modules/activity/src/Timeline/Listeners/PublishModerationToTimeline.phpapp-modules/activity/src/Timeline/Observers/ModerationEventObserver.phpapp-modules/activity/src/Timeline/Queries/TimelineFeed.phpapp-modules/activity/src/Timeline/Timeline.phpapp-modules/activity/tests/Unit/Timeline/CreatePostTest.phpapp-modules/activity/tests/Unit/Timeline/CreateReplyTest.phpapp-modules/activity/tests/Unit/Timeline/DeleteReplyTest.phpapp-modules/activity/tests/Unit/Timeline/PublishModerationEntryTest.phpapp-modules/activity/tests/Unit/Timeline/TimelineFeedQueryTest.phpapp-modules/activity/tests/Unit/Timeline/TimelineModelTest.phpapp-modules/activity/tests/Unit/Timeline/TogglePinPostTest.phpapp-modules/panel-app/.gitignoreapp-modules/panel-app/composer.jsonapp-modules/panel-app/config/panel-admin.phpapp-modules/panel-app/resources/views/components/timeline/engagement.blade.phpapp-modules/panel-app/resources/views/components/timeline/header.blade.phpapp-modules/panel-app/resources/views/components/timeline/moderation-event.blade.phpapp-modules/panel-app/resources/views/components/timeline/post-entry.blade.phpapp-modules/panel-app/resources/views/dashboard.blade.phpapp-modules/panel-app/resources/views/livewire/timeline/composer.blade.phpapp-modules/panel-app/resources/views/livewire/timeline/feed.blade.phpapp-modules/panel-app/resources/views/livewire/timeline/post-show.blade.phpapp-modules/panel-app/resources/views/livewire/timeline/reply-composer.blade.phpapp-modules/panel-app/resources/views/livewire/timeline/thread-replies.blade.phpapp-modules/panel-app/resources/views/pages/thread.blade.phpapp-modules/panel-app/src/Livewire/Timeline/Composer.phpapp-modules/panel-app/src/Livewire/Timeline/Concerns/HasLoadMore.phpapp-modules/panel-app/src/Livewire/Timeline/Feed.phpapp-modules/panel-app/src/Livewire/Timeline/PostShow.phpapp-modules/panel-app/src/Livewire/Timeline/ReplyComposer.phpapp-modules/panel-app/src/Livewire/Timeline/ThreadReplies.phpapp-modules/panel-app/src/Pages/ThreadPage.phpapp-modules/panel-app/src/Pages/TimelinePage.phpapp-modules/panel-app/src/PanelAppServiceProvider.phpapp-modules/panel-app/tests/Feature/Timeline/ThreadPageTest.phpapp-modules/panel-hub/resources/views/dashboard.blade.phpapp-modules/panel-hub/src/Pages/Dashboard.phpapp-modules/panel-hub/src/PanelHubServiceProvider.phpapp/Enums/FilamentPanel.phpapp/Providers/Filament/AppPanelProvider.phpbootstrap/providers.phpcomposer.json
💤 Files with no reviewable changes (4)
- app-modules/panel-hub/resources/views/dashboard.blade.php
- app-modules/panel-hub/src/PanelHubServiceProvider.php
- app-modules/activity/routes/message-routes.php
- app-modules/panel-hub/src/Pages/Dashboard.php
gvieira18
left a comment
There was a problem hiding this comment.
There are just two minor changes, is not a problem at all.
Have we added the module template to this project? I didn't see any PHPStan files inside it.
|
LGTM |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app-modules/panel-app/src/Pages/ThreadPage.php`:
- Line 13: The public Livewire property $record on the ThreadPage component can
be tampered with client-side; add the Locked attribute to it to prevent
post-mount changes. Modify the ThreadPage class by importing
Livewire\Attributes\Locked (add a use statement) and annotate the property as
#[Locked] public string $record; so Livewire treats it as a locked property and
prevents client-side mutation after mount.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Central YAML (inherited)
Review profile: CHILL
Plan: Pro
Run ID: a07fba92-fa9d-4e8b-a704-82d4987a7a47
📒 Files selected for processing (9)
app-modules/activity/src/Timeline/Actions/CreatePost.phpapp-modules/activity/src/Timeline/Actions/CreateReply.phpapp-modules/activity/src/Timeline/Actions/DeleteReply.phpapp-modules/activity/src/Timeline/DTOs/CreateReplyDTO.phpapp-modules/activity/tests/Unit/Timeline/CreateReplyTest.phpapp-modules/panel-app/resources/views/components/timeline/engagement.blade.phpapp-modules/panel-app/src/Livewire/Timeline/Feed.phpapp-modules/panel-app/src/Livewire/Timeline/ReplyComposer.phpapp-modules/panel-app/src/Pages/ThreadPage.php
🚧 Files skipped from review as they are similar to previous changes (7)
- app-modules/activity/src/Timeline/DTOs/CreateReplyDTO.php
- app-modules/activity/src/Timeline/Actions/DeleteReply.php
- app-modules/activity/src/Timeline/Actions/CreatePost.php
- app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php
- app-modules/activity/src/Timeline/Actions/CreateReply.php
- app-modules/activity/tests/Unit/Timeline/CreateReplyTest.php
- app-modules/panel-app/resources/views/components/timeline/engagement.blade.php
|
Muito massa a feature, ficou bem estruturada. Mas quero levantar alguns pontos sobre o que pode dar errado numa plataforma com feed social + moderação pública, pensando principalmente em quem vai ser afetado por isso no dia a dia. Os contras que vejo hoje1 - Exposição de quem denuncia. Hoje o 2 - Denúncia sem evidência = palavra contra palavra. Sem um mecanismo pra anexar provas (prints, screenshots), o moderador fica no escuro e denúncias legítimas perdem peso. Pior: abre espaço pra denúncia falsa, galera fazendo report só pra zoar. Pontos que pensei que poderiam "melhorar" a ação de visibilidade antes de expor o denunciado no feed e a veracidade das denúncias
Essa foi uma análise que fiz do feed. Tudo pode ser levado apenas como um alerta, mas eu gostaria de deixar a minha visão como mulher e minoria em uma comunidade no Discord e em outros lugares também. Fico à disposição para falar mais sobre e entender outros caminhos. 💜💜💜 |
Ótimos pontos! Mas vamo lá:
Suas sugestões vão virar issues e vou trabalhar nelas hoje! Obrigado pelo feedback (10 pontos pra vc em qlqr lugar q vc queira (n tem ponto em lugar nenhum mas agradecido!!)) |
- Rename panel-hub → panel-app module - Create activity_timeline and activity_post_entries tables - Add tenant_id, drop is_reported, change content to text - Add Timeline and PostEntry models with factories - Register morphMap for post_entry and moderation_event - Register prototype command in ActivityServiceProvider - Rename HubPanelProvider → AppPanelProvider - Add UI prototype variants for timeline feed # Conflicts: # app/Providers/Filament/AppPanelProvider.php # composer.json # composer.lock
… morphMap - Timeline: add tenant, postable, root/parent/children relations, HasReactions - PostEntry: add InteractsWithMedia with images collection - Register post_entry, moderation_event, timeline in morphMap - Update factories with proper defaults - Fix duplicate index in create migration - Add 3 model unit tests
- CreatePost: creates PostEntry + Timeline entry, validates content - CreateReply: 1-level thread flattening (reply-to-reply → root) - TogglePinPost: single pin per user per tenant, authorization check - TimelineFeed: tenant-scoped query, excludes replies and ignored posts - 12 unit tests covering all actions and query
- PublishModerationEntry: only Ban/Kick create timeline entries - Fix postable_id to uuidMorphs (ModerationEvent uses UUIDs) - Add HasUuids to PostEntry, change id to uuid primary key - Add migration to alter existing columns for UUID support - Use ExternalIdentity.model_id for user resolution - 3 unit tests (ban publishes, kick publishes, warn ignored)
- Feed component with HasLoadMore infinite scroll - PostShow component with togglePin action - Blade: header, post-entry, moderation-event (hero block), engagement - TimelinePage with composer Action (MarkdownEditor + image upload) - Register ModerationEvent::created observer in ActivityServiceProvider - Register timeline-feed and timeline-post-show Livewire components - Update dashboard view to render live feed
- Add PublishModerationToTimeline listener for cross-module integration - Register listener via Event::listen(ActionExecuted::class) in ActivityServiceProvider - Add moderation_action to morphMap - Update moderation-event Blade to handle both ModerationEvent (Discord) and ModerationAction (web panel) with unified display - Show report count and violation type from linked ModerationCase
Replace broken dynamic Tailwind class with x-show + x-cloak + x-transition on the FileUpload field wrapper via extraFieldWrapperAttributes.
FileUpload stores relative paths (strings), not UploadedFile objects.
Use Storage::disk('public')->path() to resolve the full path before
passing to Spatie's addMedia().
Dedicated thread page at /app/{tenant}/timeline/{id} with reply composer,
reply listing, and delete-own-reply support. Comment icon in feed now
navigates to thread page. Replies only allowed on post_entry, not
moderation events.
Remove min-w-xl constraint, add responsive padding (px-3 sm:px-4), hide @username and pin text on small screens, scale down avatars, and reduce gaps for mobile viewports.
TimelinePage no longer needs the header action modal — replaced by inline Composer. Also removes dead message-routes and fixes docs blockquote formatting.
Prototypes served their purpose during design exploration — removing the command, state helper, and UI variant views.
- Scope all Timeline queries by tenant_id in PostShow, ThreadPage, and ThreadReplies to prevent cross-tenant data access - Wrap CreatePost and CreateReply in DB::transaction() to prevent orphaned PostEntry records on failure - Add maxLength(5000) to Composer and ReplyComposer forms - Remove dead prototype command registration from ActivityServiceProvider
- Lock $perPage with #[Locked] to prevent Livewire protocol tampering - Paginate ThreadReplies with simplePaginate + HasLoadMore (was unbounded) - Hard-delete PostEntry on reply deletion (was soft-deleted, creating orphans) - Remove limit(3) from eager-load, use ->take(3) in Blade instead - Only attribute moderation entries to moderator, never fall back to subject - Add composite index (tenant_id, parent_id, is_ignored, created_at)
- Add wire:loading.attr="disabled" to composer submit buttons - Rename delete event to timeline.reply-deleted (was misleading) - Add @continue null check on $reply->postable in views - Remove dead discoverPages call from PanelAppServiceProvider - Remove views counter from engagement (never incremented) - Wrap TogglePinPost in DB::transaction to prevent race condition - Default TimelineFactory tenant_id to Tenant::factory()
Larastan infers user_id as int from foreignIdFor migration, but it's a UUID string. Add explicit @Property types to resolve strict comparison and undefined property errors.
- Use getMorphClass() instead of hardcoded postable_type strings - Use user_id directly in listener instead of resolving ExternalIdentity - Extract ModerationEventObserver from inline callback - Use filament()->getTenant()->getKey() helper consistently - Remove redundant getTitle() from ThreadPage - Consolidate 6 WIP migrations into 2 clean create-table migrations inside the activity module (removed from database/migrations/)
Replace all hardcoded 'post_entry' and 'moderation_event' strings with (new Model)->getMorphClass() calls for consistency with the morph map registered in ActivityServiceProvider.
The external_identity_id and moderator_identity_id columns have FK constraints to external_identities table. Passing user_ids directly caused QueryException in CI. Restored resolveIdentity lookup with getMorphClass() for the model_type filter.
- Use #[ObservedBy] attribute on ModerationEvent model instead of ModerationEvent::observe() in ActivityServiceProvider - Extract shared tenant/user setup into beforeEach() in CreatePostTest
- Use addMediaFromDisk() instead of Storage::path() for S3 compatibility - Add tenantId to CreateReplyDTO, scope parent lookup by tenant - Wrap DeleteReply in DB::transaction - Set type="button" on pin button to prevent form submission - Remove children eager-load from Feed (PostShow handles its own) - Remove unused getTimeline() from ThreadPage
68ed020 to
a713dbe
Compare
## Summary - Remove the legacy events module (models, enums, actions, migrations, and pivot tables) - Add a migration to drop all legacy event-related tables (`events`, `events_talks`, `events_attendees`, `sponsors`, `events_sponsors`, `events_agenda`, `event_submission_speakers`) - Clean up references to the old events system that was replaced by the new timeline/social feed ## Context The events module was superseded by the new timeline social feed (#223) and moderation system. The old models (`EventModel`, `EventAgenda`, `EventSegment`, `EventSubmission`, etc.) and their associated enums, actions, and pivot tables are no longer used. ## Test plan - [ ] Run `php artisan migrate` to confirm the drop migration executes cleanly - [ ] Run `php artisan test --compact` to verify no remaining references to removed classes - [ ] Confirm no Filament resources reference the deleted models <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Description This PR removes the legacy events system (models, enums, actions, and related database structures) that was superseded by the new timeline social feed (`#223`) and moderation system. The changes include deletion of 26 files—database migrations, Eloquent models, pivot tables, enums, and action classes—replaced by a single migration that drops all event-related tables in a single transaction. ## References - [`#223` Timeline Social Feed](#223) — feat(timeline): social feed with posts, replies, and moderation events - [`#234` Moderation Pipeline](#234) — refactor(moderation): hybrid pipeline with event-driven enforcement - [`#215` Discord Bot Moderation](#215) — feat(moderation): discord bot moderation system ## Contributor Summary | Contributor | Lines Added | Lines Removed | Files Changed | |---|---|---|---| | danielhe4rt | 20 | 1,271 | 27 | ## Changes Summary | File Path | Change Description | |---|---| | `app-modules/events/database/migrations/2025_11_05_191403_create_events_table.php` | Removed migration creating `events` table | | `app-modules/events/database/migrations/2025_11_05_192008_create_events_talks_table.php` | Removed migration creating `events_talks` table | | `app-modules/events/database/migrations/2025_11_05_192756_create_events_attendees_table.php` | Removed migration creating `events_attendees` pivot table | | `app-modules/events/database/migrations/2025_11_05_193042_create_sponsors_table.php` | Removed migration creating `sponsors` table | | `app-modules/events/database/migrations/2025_11_05_193141_create_events_sponsors_table.php` | Removed migration creating `events_sponsors` pivot table | | `app-modules/events/database/migrations/2025_11_27_132714_add_attend_order_to_events_attendees_table.php` | Removed migration adding `attend_order` column | | `app-modules/events/database/migrations/2025_11_27_145728_create_events_agenda_table.php` | Removed migration creating `events_agenda` table | | `app-modules/events/database/migrations/2025_11_27_153537_remove_starts_at_ends_at_columns_to_events_talks_table.php` | Removed migration removing timestamp columns | | `app-modules/events/database/migrations/2025_11_27_171411_create_event_submission_speakers_table.php` | Removed migration creating `event_submission_speakers` table | | `app-modules/events/database/migrations/2026_05_16_195205_drop_events_module_tables.php` | Added new migration dropping all legacy event tables | | `app-modules/events/src/Actions/AttendEventAction.php` | Removed action class for event attendance | | `app-modules/events/src/Actions/LeaveEventAction.php` | Removed action class for leaving events | | `app-modules/events/src/Enums/AttendingStatusEnum.php` | Removed enum for attendance statuses | | `app-modules/events/src/Enums/EventTypeEnum.php` | Removed enum for event types | | `app-modules/events/src/Enums/SchedulableTypeEnum.php` | Removed enum for schedulable types | | `app-modules/events/src/Enums/SponsoringLevelEnum.php` | Removed enum for sponsoring levels | | `app-modules/events/src/Enums/Talks/TalkStatusEnum.php` | Removed enum for talk statuses | | `app-modules/events/src/Models/EventAgenda.php` | Removed `EventAgenda` model | | `app-modules/events/src/Models/EventModel.php` | Removed main `EventModel` with relationships and operations | | `app-modules/events/src/Models/EventSegment.php` | Removed `EventSegment` model | | `app-modules/events/src/Models/EventSubmission.php` | Removed `EventSubmission` model | | `app-modules/events/src/Models/Pivot/EventAttend.php` | Removed `EventAttend` pivot model | | `app-modules/events/src/Models/Pivot/EventSubmissionSpeaker.php` | Removed `EventSubmissionSpeaker` pivot model | | `app-modules/events/src/Models/Pivot/SponsorAttend.php` | Removed `SponsorAttend` pivot model | | `app-modules/events/src/Models/Sponsor.php` | Removed `Sponsor` model | <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/he4rt/heartdevs.com/pull/236?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Summary
Twitter-like social feed on the
/apppanel. Users can post (text + images), reply in threads, and see moderation events (bans/kicks) published automatically. Built on a polymorphic hub model extensible to new entry types./app/{tenant}/timeline/{id}with reply composer and paginated repliesActionExecutedevents from the moderation module automatically publish ban/kick entries to the feedArchitecture
Core changes
Timeline(UUID, polymorphicpostable, threading viaroot_id/parent_id,HasReactions, tenant-scoped),PostEntry(UUID, SoftDeletes, HasMedia)CreatePost,CreateReply(both atomic viaDB::transaction),DeleteReply(ownership + tenant check),TogglePinPost(single pin per tenant, transactional),PublishModerationEntryCreatePostDTO,CreateReplyDTOTimelineFeed— filters by tenant, excludes replies and ignored entries, orders bycreated_at DESCPublishModerationToTimelinelistener onActionExecuted→ convertsModerationActionintoModerationEvent→ observer creates Timeline entryFeed(infinite scroll),PostShow(post card),Composer(inline post form),ReplyComposer,ThreadReplies(paginated, delete-own)TimelinePage(dashboard),ThreadPage(/timeline/{record}, tenant-validated)#[Locked]on IDs and$perPage,DB::transactionon writes,maxLength(5000)on content,wire:loading.attr="disabled"on submit buttonsFile tree
Test plan
Automated (32 tests passing)
CreatePostTest— creates post, rejects empty, handles long content, atomic rollback on failureCreateReplyTest— reply to root, reply-to-reply flattens, atomic rollbackDeleteReplyTest— owner can delete, cannot delete others', cannot delete root postsTogglePinPostTest— pin/unpin, only one pin per tenant, only ownerPublishModerationEntryTest— publishes ban/kick via observer, skips warn/mute, skips when no moderatorTimelineModelTest— relations, threading, morph mapTimelineFeedQueryTest— tenant scoping, excludes replies and ignoredThreadPageTest— page renders, reply submission, chronological order, delete own, tenant isolation (3 cross-tenant tests)Manual E2E checklist for reviewer
Feed
/app/{tenant}— feed loads with infinite scrollsimplePaginate)Thread page
/app/{tenant}/timeline/{id}Tenant isolation (critical)
/app/{tenant-b}/timeline/{uuid-from-tenant-a}→ should 404Mobile
Timeline Social Feed Feature
Description
Implements a Twitter-like social feed in the /app panel with posts, one-level threading, a dedicated thread page, and automatic publishing of moderation events. Features include polymorphic Timeline hub (activity_timeline), PostEntry models with media support, domain actions for creating/deleting posts and replies, tenant isolation, and 32 passing tests covering the complete feature.
References
This feature is part of PR #223. YuriSouzaDev approved with "LGTM" comment. Documentation provided in
app-modules/activity/docs/timeline.mdexplaining the polymorphic timeline architecture, moderation event ingestion flow, and process for adding new timeline entry types.Dependencies & Requirements
Contributor Summary
Changes Summary