feat(timeline): social feed with posts, replies, and moderation events#223
feat(timeline): social feed with posts, replies, and moderation events#223danielhe4rt wants to merge 31 commits into4.xfrom
Conversation
- 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
… 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
…stener ActionExecuted listener now creates a ModerationEvent (activity module) from the ModerationAction (moderation module), instead of referencing ModerationAction directly. The timeline only ever references moderation_event as postable type. - Listener resolves ExternalIdentity from User IDs for subject/moderator - Enriches metadata with case_id, reports_count, violation_type, source - Removed moderation_action from morphMap - Blade component handles only ModerationEvent, reads enriched metadata
Twitter-like post field with avatar, auto-growing textarea, Ctrl+Enter shortcut, and Postar button. Renders above the feed items.
Replace plain textarea with Filament HasSchemas + InteractsWithSchemas pattern. Composer now uses MarkdownEditor with toolbar (bold, italic, link, bulletList, orderedList) and proper form validation via getState().
Replace MarkdownEditor with Textarea (autosize, compact) and FileUpload for images (up to 4). Matches the previous Twitter-like design with avatar left, clean input, and footer bar with Ctrl+Enter hint + button.
FileUpload hidden by default, revealed via photo icon button in footer. Alpine.js toggles visibility class on the upload field container. Icon highlights when upload area is active.
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.
📝 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
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
danielhe4rt
left a comment
There was a problem hiding this comment.
Some nitpicks but looks good so far.
- 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.
| uses(RefreshDatabase::class); | ||
|
|
||
| test('creates a post entry and timeline record', function (): void { | ||
| $tenant = Tenant::factory()->create(); |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
- Use #[ObservedBy] attribute on ModerationEvent model instead of ModerationEvent::observe() in ActivityServiceProvider - Extract shared tenant/user setup into beforeEach() in CreatePostTest
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
| $userId = $event->moderator?->model_id; | ||
|
|
||
| if ($userId === null) { | ||
| return null; | ||
| } | ||
|
|
||
| return Timeline::query()->create([ | ||
| 'user_id' => $userId, | ||
| 'tenant_id' => $event->tenant_id, | ||
| 'postable_type' => $event->getMorphClass(), |
There was a problem hiding this comment.
Enforce moderator identity tenant/model constraints before creating timeline rows.
At Line 24, the action trusts moderator->model_id without validating that the moderator identity belongs to the same tenant and represents a User. This can write misattributed timeline entries in multi-tenant scenarios.
🔧 Proposed fix
use He4rt\Activity\Moderation\Enums\ModerationType;
use He4rt\Activity\Moderation\Models\ModerationEvent;
use He4rt\Activity\Timeline\Timeline;
+use He4rt\Identity\User\Models\User;
@@
- $userId = $event->moderator?->model_id;
-
- if ($userId === null) {
+ $moderator = $event->moderator;
+ if (
+ $moderator === null
+ || $moderator->tenant_id !== $event->tenant_id
+ || $moderator->model_type !== (new User)->getMorphClass()
+ || $moderator->model_id === null
+ ) {
return null;
}
return Timeline::query()->create([
- 'user_id' => $userId,
+ 'user_id' => $moderator->model_id,
'tenant_id' => $event->tenant_id,
'postable_type' => $event->getMorphClass(),
'postable_id' => $event->id,
]);🤖 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/Actions/PublishModerationEntry.php` around
lines 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.
| ModerationEvent::query()->create([ | ||
| 'tenant_id' => $tenantId, | ||
| 'external_identity_id' => $subjectIdentityId, | ||
| 'moderator_identity_id' => $moderatorIdentityId, | ||
| 'type' => ModerationType::from($action->action_type->value), | ||
| 'reason' => $action->reason ?? $case?->content_snapshot['text'] ?? null, | ||
| 'metadata' => [ | ||
| 'source' => 'web_panel', | ||
| 'case_id' => $case?->id, | ||
| 'moderator_visible' => true, | ||
| 'action_id' => $action->id, | ||
| 'reports_count' => $case?->reports()?->count() ?? 0, | ||
| 'violation_type' => $case?->violation_type?->value, | ||
| ], | ||
| 'occurred_at' => $action->created_at ?? now(), | ||
| ]); |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Consider adding transaction and duplicate prevention.
The ModerationEvent creation lacks:
- Transaction wrapping - If the subsequent Timeline creation (via observer) fails, this could leave orphaned ModerationEvent records
- Duplicate prevention - If the event fires multiple times or is retried, multiple timeline entries could be created for the same action
Consider wrapping in DB::transaction() and adding updateOrCreate() with action_id as a unique constraint.
🔒 Proposed transaction and idempotency improvement
+use Illuminate\Support\Facades\DB;
+
public function handle(ActionExecuted $event): void
{
// ... existing validation ...
- ModerationEvent::query()->create([
+ DB::transaction(function () use ($tenantId, $subjectIdentityId, $moderatorIdentityId, $action, $case) {
+ ModerationEvent::query()->updateOrCreate(
+ ['action_id' => $action->id],
+ [
- 'tenant_id' => $tenantId,
+ 'tenant_id' => $tenantId,
// ... rest of fields ...
- ]);
+ ]
+ );
+ });
}Note: This requires adding a unique index on action_id in the migration.
🤖 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 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.
| <img | ||
| src="{{ $media->getUrl() }}" | ||
| alt="" | ||
| class="max-h-80 w-full rounded-lg border border-gray-200 object-cover dark:border-white/10" |
There was a problem hiding this comment.
Add descriptive alt text for uploaded images.
Line 13 uses an empty alt, which hides non-decorative content from assistive tech.
Suggested change
- alt=""
+ alt="{{ $media->name ?: 'Post image' }}"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <img | |
| src="{{ $media->getUrl() }}" | |
| alt="" | |
| class="max-h-80 w-full rounded-lg border border-gray-200 object-cover dark:border-white/10" | |
| <img | |
| src="{{ $media->getUrl() }}" | |
| alt="{{ $media->name ?: 'Post image' }}" | |
| class="max-h-80 w-full rounded-lg border border-gray-200 object-cover dark:border-white/10" |
🤖 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/post-entry.blade.php`
around lines 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.
| str(auth()->user()->name) | ||
| ->substr(0, 2) | ||
| ->upper() | ||
| }} |
There was a problem hiding this comment.
Add defensive handling for short or empty user names.
The code assumes auth()->user()->name exists and has at least 2 characters. If the name is shorter or null (e.g., from incomplete profile data), substr(0, 2) may produce unexpected results or errors.
🛡️ Proposed fix to handle edge cases
{{
- str(auth()->user()->name)
+ str(auth()->user()->name ?? 'U')
->substr(0, 2)
+ ->padRight(2, '?')
->upper()
}}Or use a more robust fallback:
{{
- str(auth()->user()->name)
- ->substr(0, 2)
- ->upper()
+ str(auth()->user()->name ?? '')
+ ->whenEmpty(fn() => 'U')
+ ->limit(2, '')
+ ->upper()
+ ->whenEmpty(fn() => 'US')
}}🤖 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/composer.blade.php`
around lines 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.
| @foreach ($reply->postable->getMedia('images') as $media) | ||
| <img | ||
| src="{{ $media->getUrl() }}" | ||
| alt="" |
There was a problem hiding this comment.
Add meaningful alt text for accessibility.
The empty alt="" attribute on reply images may hinder screen reader users. Consider:
- Adding descriptive alt text if available in media metadata
- Using a default like
alt="Image attached to reply" - Or explicitly marking as decorative only if that's the intent
♿ Proposed accessibility improvement
<img
src="{{ $media->getUrl() }}"
- alt=""
+ alt="Image attached to reply"
class="max-h-60 w-full rounded-lg border border-gray-200 object-cover dark:border-white/10"
loading="lazy"
/>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| alt="" | |
| <img | |
| src="{{ $media->getUrl() }}" | |
| alt="Image attached to reply" | |
| class="max-h-60 w-full rounded-lg border border-gray-200 object-cover dark:border-white/10" | |
| loading="lazy" | |
| /> |
🤖 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/thread-replies.blade.php`
at 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.
| public function mount(string $record): void | ||
| { | ||
| $exists = Timeline::query() | ||
| ->where('id', $record) | ||
| ->where('tenant_id', filament()->getTenant()->getKey()) | ||
| ->whereNull('parent_id') | ||
| ->exists(); | ||
|
|
||
| abort_unless($exists, 404); | ||
|
|
||
| $this->record = $record; | ||
| } | ||
|
|
||
| public function getTimeline(): Timeline | ||
| { | ||
| return Timeline::query() | ||
| ->where('id', $this->record) | ||
| ->where('tenant_id', filament()->getTenant()->getKey()) | ||
| ->whereNull('parent_id') | ||
| ->with(['user', 'postable', 'reactions']) | ||
| ->withCount('children', 'reactions') | ||
| ->firstOrFail(); | ||
| } |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
- 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
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
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